@lensmcp/cluster 1.0.0

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 (69) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +130 -0
  3. package/basic-ssl.d.ts +7 -0
  4. package/basic-ssl.d.ts.map +1 -0
  5. package/basic-ssl.js +158 -0
  6. package/build-scope-patterns.d.ts +12 -0
  7. package/build-scope-patterns.d.ts.map +1 -0
  8. package/build-scope-patterns.js +40 -0
  9. package/create-webpack-dev.d.ts +27 -0
  10. package/create-webpack-dev.d.ts.map +1 -0
  11. package/create-webpack-dev.js +151 -0
  12. package/create-webpack-prod.d.ts +28 -0
  13. package/create-webpack-prod.d.ts.map +1 -0
  14. package/create-webpack-prod.js +169 -0
  15. package/executors/build/build.impl.d.ts +19 -0
  16. package/executors/build/build.impl.d.ts.map +1 -0
  17. package/executors/build/build.impl.js +98 -0
  18. package/executors/build/schema.d.ts +35 -0
  19. package/executors/build/schema.json +135 -0
  20. package/executors/gateway/gateway.impl.d.ts +23 -0
  21. package/executors/gateway/gateway.impl.d.ts.map +1 -0
  22. package/executors/gateway/gateway.impl.js +39 -0
  23. package/executors/gateway/gateway.lib.d.ts +130 -0
  24. package/executors/gateway/gateway.lib.d.ts.map +1 -0
  25. package/executors/gateway/gateway.lib.js +797 -0
  26. package/executors/gateway/jwks-verify.d.ts +28 -0
  27. package/executors/gateway/jwks-verify.d.ts.map +1 -0
  28. package/executors/gateway/jwks-verify.js +121 -0
  29. package/executors/gateway/main.prod-gateway.d.ts +2 -0
  30. package/executors/gateway/main.prod-gateway.d.ts.map +1 -0
  31. package/executors/gateway/main.prod-gateway.js +290 -0
  32. package/executors/gateway/main.rollout.d.ts +2 -0
  33. package/executors/gateway/main.rollout.d.ts.map +1 -0
  34. package/executors/gateway/main.rollout.js +130 -0
  35. package/executors/gateway/manifest.d.ts +275 -0
  36. package/executors/gateway/manifest.d.ts.map +1 -0
  37. package/executors/gateway/manifest.js +344 -0
  38. package/executors/gateway/prod-gateway.lib.d.ts +58 -0
  39. package/executors/gateway/prod-gateway.lib.d.ts.map +1 -0
  40. package/executors/gateway/prod-gateway.lib.js +535 -0
  41. package/executors/gateway/providers-prod.d.ts +46 -0
  42. package/executors/gateway/providers-prod.d.ts.map +1 -0
  43. package/executors/gateway/providers-prod.js +199 -0
  44. package/executors/gateway/registry-source.d.ts +68 -0
  45. package/executors/gateway/registry-source.d.ts.map +1 -0
  46. package/executors/gateway/registry-source.js +131 -0
  47. package/executors/gateway/rollout-ops.d.ts +54 -0
  48. package/executors/gateway/rollout-ops.d.ts.map +1 -0
  49. package/executors/gateway/rollout-ops.js +167 -0
  50. package/executors/gateway/schema.d.ts +10 -0
  51. package/executors/gateway/schema.json +30 -0
  52. package/executors/serve/schema.d.ts +53 -0
  53. package/executors/serve/schema.json +196 -0
  54. package/executors/serve/serve.impl.d.ts +25 -0
  55. package/executors/serve/serve.impl.d.ts.map +1 -0
  56. package/executors/serve/serve.impl.js +243 -0
  57. package/executors/trust/schema.d.ts +6 -0
  58. package/executors/trust/schema.json +20 -0
  59. package/executors/trust/trust.impl.d.ts +42 -0
  60. package/executors/trust/trust.impl.d.ts.map +1 -0
  61. package/executors/trust/trust.impl.js +126 -0
  62. package/executors.json +24 -0
  63. package/index.d.ts +4 -0
  64. package/index.d.ts.map +1 -0
  65. package/index.js +9 -0
  66. package/main.devserver.d.ts +16 -0
  67. package/main.devserver.d.ts.map +1 -0
  68. package/main.devserver.js +812 -0
  69. package/package.json +66 -0
@@ -0,0 +1,812 @@
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
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
29
+ const httpProxy = require('http-proxy');
30
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
31
+ const { prettyFactory } = require('pino-pretty');
32
+ // ----------------------------- Shared Config ----------------------------------
33
+ const BUNDLE_PATH = process.env.BUNDLE_PATH?.trim() || './main.js';
34
+ const SERVICE_NAME = process.env.SERVICE_NAME || '';
35
+ const SERVICE_PREFIX = process.env.SERVE_PREFIX ? `/${process.env.SERVE_PREFIX}` : '';
36
+ const GATEWAY_MIDDLEWARE_PATH = process.env.GATEWAY_MIDDLEWARE || '';
37
+ const GATEWAY_CONFIG = (() => {
38
+ const raw = process.env.GATEWAY_CONFIG;
39
+ if (!raw)
40
+ return {};
41
+ try {
42
+ return JSON.parse(raw);
43
+ }
44
+ catch {
45
+ return {};
46
+ }
47
+ })();
48
+ let gatewayMiddleware = null;
49
+ if (GATEWAY_MIDDLEWARE_PATH) {
50
+ try {
51
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
52
+ const mod = require(GATEWAY_MIDDLEWARE_PATH);
53
+ gatewayMiddleware = typeof mod === 'function' ? mod : (typeof mod.default === 'function' ? mod.default : null);
54
+ if (!gatewayMiddleware) {
55
+ console.warn(`[gateway] ${GATEWAY_MIDDLEWARE_PATH} does not export a function, gateway disabled`);
56
+ }
57
+ }
58
+ catch (err) {
59
+ console.error(`[gateway] Failed to load middleware from ${GATEWAY_MIDDLEWARE_PATH}:`, err);
60
+ }
61
+ }
62
+ function applyGatewayMiddleware(req) {
63
+ if (!gatewayMiddleware)
64
+ return;
65
+ try {
66
+ gatewayMiddleware(req, GATEWAY_CONFIG);
67
+ }
68
+ catch (err) {
69
+ console.error('[gateway] Middleware error:', err);
70
+ }
71
+ }
72
+ const GATEWAY_ROUTES = (() => {
73
+ try {
74
+ return JSON.parse(process.env.GATEWAY_ROUTES || '[]');
75
+ }
76
+ catch {
77
+ return [];
78
+ }
79
+ })();
80
+ const GATEWAY_HTTPS = process.env.GATEWAY_HTTPS === '1';
81
+ function hostMatches(pattern, host) {
82
+ if (pattern.startsWith('*.')) {
83
+ const suffix = pattern.slice(1); // ".tetros.localhost"
84
+ return host.endsWith(suffix) || host === pattern.slice(2);
85
+ }
86
+ return host === pattern;
87
+ }
88
+ function matchGatewayRoute(req) {
89
+ if (GATEWAY_ROUTES.length === 0)
90
+ return undefined;
91
+ const host = (req.headers.host || '').split(':')[0];
92
+ const url = req.url || '/';
93
+ for (const route of GATEWAY_ROUTES) {
94
+ if (route.host && !hostMatches(route.host, host))
95
+ continue;
96
+ if (route.prefix && !url.startsWith(route.prefix))
97
+ continue;
98
+ return route;
99
+ }
100
+ return undefined;
101
+ }
102
+ // ----------------------------- Worker Config ------------------------------------
103
+ const WORKER_NAMES = (() => {
104
+ try {
105
+ return JSON.parse(process.env.WORKERS || '[]');
106
+ }
107
+ catch {
108
+ return [];
109
+ }
110
+ })();
111
+ // ----------------------------- Socket Helpers ---------------------------------
112
+ const SOCK_DIR = path.join(os.tmpdir(), `${SERVICE_NAME || 'lensmcp-cluster'}-devserver`);
113
+ function childSockPath(id) {
114
+ return path.join(SOCK_DIR, `child-${id}.sock`);
115
+ }
116
+ function cleanupSock(sockPath) {
117
+ if (!sockPath)
118
+ return;
119
+ try {
120
+ fs.unlinkSync(sockPath);
121
+ }
122
+ catch { }
123
+ }
124
+ // delete from CJS cache + require fresh
125
+ async function importFresh(spec) {
126
+ process.env.DEVSERVER_MODE = '1';
127
+ const resolved = path.isAbsolute(spec) ? spec : path.join(__dirname, spec);
128
+ eval(`delete require.cache["${resolved}"]`); // delete from CJS cache
129
+ return eval(`require("${resolved}")`);
130
+ }
131
+ function debounce(fn, ms) {
132
+ let t = null;
133
+ return (...args) => {
134
+ if (t)
135
+ clearTimeout(t);
136
+ t = setTimeout(() => fn(...args), ms);
137
+ };
138
+ }
139
+ // ===================================================================================
140
+ // CHILD MODE (APP_RUNNER=1) — loads bundle, bootstraps NestJS app, listens on socket
141
+ // ===================================================================================
142
+ if (process.env.APP_RUNNER === '1') {
143
+ // Zero-touch instrumentation: load BEFORE the app bundle so the require
144
+ // hook sees pg/ioredis/bullmq/@nestjs/core on their first load, builtins
145
+ // (fs/net/exec) are tapped, NestFactory.create grafts the lens module, and
146
+ // the child-app bridge owns global.createChildApp — a completely plain
147
+ // main.ts (NestFactory.create + app.listen) becomes a pod with no
148
+ // devserver contract in host source. Legacy dual-mode bundles overwrite
149
+ // global.createChildApp at import and keep working unchanged.
150
+ try {
151
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
152
+ require('@lensmcp/node-instrumentation/register');
153
+ }
154
+ catch { /* instrumentation package absent — pods run untapped */ }
155
+ (async () => {
156
+ let current = null;
157
+ let swapping = false;
158
+ let pendingReload = false;
159
+ // Delegate starts as 503; swapped to the real handler after first boot
160
+ let delegate = (_req, res) => {
161
+ res.statusCode = 503;
162
+ res.end('starting');
163
+ };
164
+ async function swapNow() {
165
+ if (swapping) {
166
+ pendingReload = true;
167
+ console.log('[child] Reload deferred — will re-swap after current swap completes');
168
+ return current;
169
+ }
170
+ swapping = true;
171
+ // ── Stale-handler cleanup ──
172
+ // @frontegg/nestjs-common/app-builder registers process.on('uncaughtException')
173
+ // during build() and never removes it on close. After swap, the OLD handler
174
+ // fires during background teardown and crashes because its AsyncLocalStorage
175
+ // context is destroyed. Fix: wipe all handlers before re-import, then add
176
+ // a safe catch-all. The fresh bundle will register its own valid handlers.
177
+ process.removeAllListeners('uncaughtException');
178
+ process.removeAllListeners('unhandledRejection');
179
+ process.on('uncaughtException', (err, origin) => {
180
+ console.error(`[child] uncaughtException (${origin}):`, err);
181
+ });
182
+ process.on('unhandledRejection', (reason) => {
183
+ // Suppress known stale-context rejections from previous app's background teardown.
184
+ const msg = reason instanceof Error ? reason.stack || reason.message : String(reason);
185
+ if (msg.includes('FronteggContextScope') || msg.includes('populateLoggerMetadata')) {
186
+ return; // swallow — old app context is gone, nothing to do
187
+ }
188
+ console.error('[child] unhandledRejection:', reason);
189
+ });
190
+ await importFresh(BUNDLE_PATH);
191
+ if (typeof global.createChildApp !== 'function') {
192
+ swapping = false;
193
+ throw new Error(`Bundle '${BUNDLE_PATH}' does not export createChildApp() and the zero-touch ` +
194
+ `bridge is not active. Either install @lensmcp/node-instrumentation (a plain ` +
195
+ `NestFactory.create + app.listen main.ts then just works) or set ` +
196
+ `global.createChildApp in main.ts when DEVSERVER_MODE === '1'.`);
197
+ }
198
+ const next = await global.createChildApp();
199
+ const prev = current;
200
+ current = next;
201
+ delegate = next.handler;
202
+ console.log(`[child] Swapped to fresh app (previous closing in background)`);
203
+ if (prev)
204
+ prev.close().catch(err => console.error('[child] background close error:', err));
205
+ swapping = false;
206
+ if (pendingReload) {
207
+ pendingReload = false;
208
+ return swapNow();
209
+ }
210
+ return next;
211
+ }
212
+ // Create ONE debounced swapper that lives across requests
213
+ const debouncedSwap = debounce(async () => {
214
+ try {
215
+ console.log('[child] /webpack/reload');
216
+ await swapNow();
217
+ }
218
+ catch (e) {
219
+ console.error('[child] reload failed:', e);
220
+ }
221
+ }, 250);
222
+ // Raw HTTP server: admin reload endpoint + delegate all else to NestJS handler
223
+ const server = http.createServer((req, res) => {
224
+ if (req.method === 'POST' && req.url === '/webpack/reload') {
225
+ res.writeHead(200, { 'content-type': 'application/json' });
226
+ res.end(JSON.stringify({ ok: true }));
227
+ debouncedSwap();
228
+ return;
229
+ }
230
+ delegate(req, res);
231
+ });
232
+ const sockPath = process.env.CHILD_SOCK_PATH;
233
+ // ORPHAN GUARD: if the parent devserver dies (idle-kill, crash, tree
234
+ // SIGTERM that missed us), the IPC channel closes — exit immediately and
235
+ // remove our socket so no ghost pod (live process + stale sock) survives.
236
+ process.on('disconnect', () => {
237
+ try {
238
+ fs.unlinkSync(sockPath);
239
+ }
240
+ catch { /* already gone */ }
241
+ process.exit(0);
242
+ });
243
+ // boot once, then listen on Unix socket
244
+ try {
245
+ await swapNow();
246
+ const resolvedBundle = path.isAbsolute(BUNDLE_PATH) ? BUNDLE_PATH : path.join(__dirname, BUNDLE_PATH);
247
+ console.log(`[child] Using bundle: ${resolvedBundle}`);
248
+ cleanupSock(sockPath); // remove stale socket from previous run
249
+ server.listen(sockPath, () => {
250
+ console.log(`[child] Listening on ${sockPath}`);
251
+ if (process.send)
252
+ process.send({ type: 'ready', socketPath: sockPath });
253
+ });
254
+ }
255
+ catch (err) {
256
+ console.error('[child] Startup error:', err);
257
+ if (process.send)
258
+ process.send({ type: 'boot-error', error: String(err?.stack || err) });
259
+ process.exitCode = 1;
260
+ return;
261
+ }
262
+ // IPC reload handler (parent-initiated reloads)
263
+ process.on('message', async (msg) => {
264
+ if (!msg || typeof msg !== 'object')
265
+ return;
266
+ if (msg.type === 'reload') {
267
+ try {
268
+ await swapNow();
269
+ if (process.send)
270
+ process.send({ type: 'reloaded', socketPath: sockPath });
271
+ }
272
+ catch (e) {
273
+ console.error('[child] reload error:', e);
274
+ if (process.send)
275
+ process.send({ type: 'reload-error', error: String(e?.stack || e) });
276
+ }
277
+ }
278
+ });
279
+ process.on('beforeExit', () => {
280
+ try {
281
+ inspector?.close?.();
282
+ }
283
+ catch {
284
+ }
285
+ });
286
+ const shutdown = async (sig) => {
287
+ console.log(`[child ${sig}] shutting down…`);
288
+ const closingApp = current?.close().catch(err => console.error('[child] close error:', err));
289
+ const closingServer = new Promise(resolve => server.close(() => resolve()));
290
+ cleanupSock(sockPath);
291
+ setTimeout(() => process.exit(0), 700).unref();
292
+ await Promise.all([closingApp, closingServer]);
293
+ process.exit(0);
294
+ };
295
+ ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT', 'SIGUSR2'].forEach(sig => {
296
+ process.on(sig, () => {
297
+ inspector?.close?.();
298
+ void shutdown(sig);
299
+ });
300
+ });
301
+ })();
302
+ }
303
+ else {
304
+ // ---------- Colored prefix helpers (no deps) ----------
305
+ const COLORS = [
306
+ '\x1b[36m', // cyan
307
+ '\x1b[33m', // yellow
308
+ '\x1b[35m', // magenta
309
+ '\x1b[32m', // green
310
+ '\x1b[34m', // blue
311
+ '\x1b[31m' // red
312
+ ];
313
+ const RESET = '\x1b[0m';
314
+ const CHILD_COUNT = Math.max(1, Number(process.env.CHILD_COUNT || 1));
315
+ const colorFor = (id) => {
316
+ return COLORS[(id - 1) % COLORS.length];
317
+ };
318
+ const tagFor = (id) => {
319
+ if (CHILD_COUNT === 1) {
320
+ return '';
321
+ }
322
+ const base = `[child#${id}] `;
323
+ return `${colorFor(id)}${base}${RESET}`;
324
+ };
325
+ const isJsonLog = (log) => {
326
+ return log.startsWith('{') && log.endsWith('}');
327
+ };
328
+ const wireProcLogging = (proc, tag) => {
329
+ const prettyLog = prettyFactory({
330
+ sync: true,
331
+ colorize: true,
332
+ crlf: true,
333
+ messageKey: 'message',
334
+ errorLikeObjectKeys: ['err', 'error'],
335
+ errorProps: 'type,message,stack',
336
+ ignore: [
337
+ 'logContext', 'context', 'hostname', 'req', 'res', 'err.driverError',
338
+ 'module', 'cloudEnvironment',
339
+ 'frontegg-application-id', 'frontegg-tenant-id',
340
+ 'frontegg-trace-id', 'frontegg-vendor-id',
341
+ 'host', 'service', 'version',
342
+ 'err', 'error',
343
+ ].join(','),
344
+ messageFormat: '{if module}[{module}] {end}{if context}[{context}] {end}{if logContext}[{logContext}] {end}{message}',
345
+ customPrettifiers: {
346
+ stack: (value) => '\n' + String(value),
347
+ },
348
+ });
349
+ const write = (kind, line) => {
350
+ const dest = kind === 'stdout' ? process.stdout : process.stderr;
351
+ const log = isJsonLog(line) ? prettyLog(line) : `${line}\n`;
352
+ dest.write(`${tag}${log}`);
353
+ };
354
+ const attach = (stream, kind) => {
355
+ if (!stream)
356
+ return;
357
+ const rl = readline.createInterface({ input: stream });
358
+ rl.on('line', (line) => write(kind, line));
359
+ rl.on('close', () => { });
360
+ };
361
+ attach(proc.stdout, 'stdout');
362
+ attach(proc.stderr, 'stderr');
363
+ };
364
+ const wireChildLogging = (info) => wireProcLogging(info.proc, tagFor(info.id));
365
+ (async () => {
366
+ // Ensure socket directory exists
367
+ fs.mkdirSync(SOCK_DIR, { recursive: true });
368
+ const proxy = httpProxy.createProxyServer({});
369
+ let publicPort = Number(process.env.PORT) || 0;
370
+ if (!publicPort)
371
+ publicPort = 9090;
372
+ const children = [];
373
+ let nextId = 1;
374
+ let rrIndex = 0;
375
+ let shuttingDown = false; // set by the parent's signal handler
376
+ let desiredPods = CHILD_COUNT; // pool target; grown by /webpack/scale
377
+ let recentCrashRespawns = []; // timestamps — crash-loop backstop
378
+ function healthyChildren() {
379
+ return children.filter(c => c.healthy && typeof c.socketPath === 'string');
380
+ }
381
+ function pickChild() {
382
+ const healthy = healthyChildren();
383
+ if (healthy.length === 0)
384
+ return null;
385
+ const idx = rrIndex % healthy.length;
386
+ rrIndex = (rrIndex + 1) % healthy.length;
387
+ return healthy[idx];
388
+ }
389
+ async function spawnChild() {
390
+ const id = nextId++;
391
+ const sockPath = childSockPath(id);
392
+ cleanupSock(sockPath); // remove stale socket
393
+ // When running from TS source (local dev), children need @swc-node/register too
394
+ const childExecArgv = ['--enable-source-maps'];
395
+ if (__filename.endsWith('.ts')) {
396
+ childExecArgv.unshift('--require', '@swc-node/register');
397
+ }
398
+ const proc = (0, node_child_process_1.fork)(__filename, {
399
+ env: {
400
+ ...process.env,
401
+ APP_RUNNER: '1',
402
+ CHILD_SOCK_PATH: sockPath,
403
+ CHILD_DEBUG_PORT: CHILD_COUNT == 1 ? '1' : '0',
404
+ },
405
+ stdio: ['inherit', 'pipe', 'pipe', 'ipc'],
406
+ execArgv: childExecArgv,
407
+ });
408
+ const info = { id, proc, healthy: false, lastError: null };
409
+ proc.on('message', (msg) => {
410
+ if (!msg || typeof msg !== 'object')
411
+ return;
412
+ const m = msg;
413
+ if (m.type === 'ready') {
414
+ info.socketPath = String(m.socketPath);
415
+ info.healthy = true;
416
+ console.log(`[parent] Child#${info.id} ready on socket ${info.socketPath}`);
417
+ }
418
+ else if (m.type === 'inspector-url') {
419
+ console.log(`[parent] Child#${info.id} inspector: ${m.url}`);
420
+ }
421
+ else if (m.type === 'reloaded') {
422
+ info.socketPath = String(m.socketPath);
423
+ info.healthy = true;
424
+ console.log(`[parent] Child#${info.id} hot-swapped on socket ${info.socketPath}`);
425
+ }
426
+ else if (m.type === 'reload-error') {
427
+ info.lastError = String(m.error || 'unknown reload error');
428
+ console.error(`[parent] Child#${info.id} reload error: ${info.lastError}`);
429
+ info.healthy = false;
430
+ }
431
+ else if (m.type === 'boot-error') {
432
+ info.lastError = String(m.error || 'unknown boot error');
433
+ console.error(`[parent] Child#${info.id} boot error: ${info.lastError}`);
434
+ info.healthy = false;
435
+ }
436
+ });
437
+ proc.on('exit', (code, signal) => {
438
+ const clean = signal === 'SIGINT' || signal === 'SIGTERM' || code === 0;
439
+ if (clean) {
440
+ console.log(`[parent] Child#${info.id} exited cleanly (code=${code}, signal=${signal ?? 'none'})`);
441
+ }
442
+ else {
443
+ console.error(`[parent] Child#${info.id} crashed (code=${code}, signal=${signal ?? 'none'})`);
444
+ if (!info.lastError)
445
+ info.lastError = `Child exited abnormally (code=${code}, signal=${signal ?? 'none'})`;
446
+ }
447
+ info.healthy = false;
448
+ cleanupSock(info.socketPath);
449
+ info.socketPath = undefined;
450
+ // Supervise the pool: a CRASHED pod (not a clean shutdown, not a
451
+ // reload/idle-kill) must be replaced or the pool silently shrinks to
452
+ // zero (with CHILD_COUNT=1, the service dies until a code edit). Drop
453
+ // the corpse and respawn after a short backoff — with a crash-loop
454
+ // backstop so a bundle that dies on boot doesn't busy-spin forever.
455
+ if (!clean && !shuttingDown) {
456
+ const i = children.indexOf(info);
457
+ if (i >= 0)
458
+ children.splice(i, 1);
459
+ const now = Date.now();
460
+ recentCrashRespawns = recentCrashRespawns.filter((t) => now - t < 30_000);
461
+ if (recentCrashRespawns.length >= 5) {
462
+ console.error(`[parent] crash-loop detected (${recentCrashRespawns.length} respawns/30s) — pausing auto-respawn; POST /webpack/reload to retry`);
463
+ return;
464
+ }
465
+ recentCrashRespawns.push(now);
466
+ setTimeout(() => {
467
+ const live = children.filter((c) => c.proc.killed === false).length;
468
+ if (!shuttingDown && live < desiredPods) {
469
+ console.log(`[parent] respawning a crashed pod (${live}/${desiredPods} live)`);
470
+ void spawnChild().catch(() => undefined); // spawnChild pushes to children + wires logging itself
471
+ }
472
+ }, 1500).unref?.();
473
+ }
474
+ });
475
+ children.push(info);
476
+ // connect stdout/stderr now that it's piped
477
+ wireChildLogging(info);
478
+ return info;
479
+ }
480
+ async function ensurePoolSize(n) {
481
+ desiredPods = Math.max(desiredPods, n); // remember the target so crash-respawn restores it
482
+ const live = children.filter(c => c.proc.killed === false);
483
+ const need = n - live.length;
484
+ for (let i = 0; i < need; i++) {
485
+ await spawnChild();
486
+ }
487
+ }
488
+ async function respawnUnhealthy() {
489
+ // Kill & replace children that are unhealthy or missing socketPaths
490
+ const toReplace = children.filter(c => !c.healthy || typeof c.socketPath !== 'string');
491
+ await Promise.all(toReplace.map(async (c) => {
492
+ try {
493
+ c.proc.kill('SIGTERM');
494
+ }
495
+ catch { /* ignore */
496
+ }
497
+ cleanupSock(c.socketPath);
498
+ c.socketPath = undefined;
499
+ // Remove from array
500
+ const idx = children.indexOf(c);
501
+ if (idx >= 0)
502
+ children.splice(idx, 1);
503
+ // Spawn a fresh one
504
+ await spawnChild();
505
+ }));
506
+ }
507
+ const workerProcesses = [];
508
+ function spawnWorkerProcess(name) {
509
+ const bundleDir = path.dirname(path.isAbsolute(BUNDLE_PATH) ? BUNDLE_PATH : path.resolve(BUNDLE_PATH));
510
+ const workerBundle = path.join(bundleDir, `${name}.js`);
511
+ if (!fs.existsSync(workerBundle)) {
512
+ console.error(`[parent] Worker "${name}" bundle not found: ${workerBundle}`);
513
+ return null;
514
+ }
515
+ const workerExecArgv = ['--enable-source-maps'];
516
+ if (__filename.endsWith('.ts')) {
517
+ workerExecArgv.unshift('--require', '@swc-node/register');
518
+ }
519
+ // Workers get the same zero-touch taps as pods (fs/net/exec/db/redis/queue).
520
+ try {
521
+ workerExecArgv.push('--require', require.resolve('@lensmcp/node-instrumentation/register'));
522
+ }
523
+ catch { /* instrumentation package absent — workers run untapped */ }
524
+ const proc = (0, node_child_process_1.fork)(workerBundle, {
525
+ env: process.env,
526
+ stdio: ['inherit', 'pipe', 'pipe', 'ipc'],
527
+ execArgv: workerExecArgv,
528
+ });
529
+ const info = { name, proc };
530
+ workerProcesses.push(info);
531
+ wireProcLogging(proc, `\x1b[35m[worker:${name}] \x1b[0m`);
532
+ proc.on('exit', (code, signal) => {
533
+ const clean = signal === 'SIGINT' || signal === 'SIGTERM' || code === 0;
534
+ if (!clean) {
535
+ console.error(`[parent] Worker "${name}" crashed (code=${code}, signal=${signal ?? 'none'})`);
536
+ }
537
+ const idx = workerProcesses.indexOf(info);
538
+ if (idx >= 0)
539
+ workerProcesses.splice(idx, 1);
540
+ });
541
+ console.log(`[parent] Worker "${name}" started (pid=${proc.pid})`);
542
+ return info;
543
+ }
544
+ function restartWorkers() {
545
+ for (const w of [...workerProcesses]) {
546
+ try {
547
+ w.proc.kill('SIGTERM');
548
+ }
549
+ catch { }
550
+ }
551
+ workerProcesses.length = 0;
552
+ for (const name of WORKER_NAMES) {
553
+ spawnWorkerProcess(name);
554
+ }
555
+ }
556
+ // Create ONE debounced forwarder that lives across requests
557
+ const debouncedForwardReload = debounce(async () => {
558
+ const healthies = healthyChildren();
559
+ if (healthies.length > 0) {
560
+ console.log(`[parent] Forwarding reload to ${healthies.length} child(ren)`);
561
+ for (const c of healthies)
562
+ c.proc.send?.({ type: 'reload' });
563
+ }
564
+ await respawnUnhealthy();
565
+ if (WORKER_NAMES.length > 0)
566
+ restartWorkers();
567
+ }, 200);
568
+ // Start parent server (stable port)
569
+ const requestHandler = async (req, res) => {
570
+ // Admin endpoint: trigger rolling hot-reload & respawn crashed
571
+ if (req.method === 'POST' && req.url === '/webpack/reload') {
572
+ res.writeHead(200, { 'content-type': 'application/json' });
573
+ res.end(JSON.stringify({ ok: true }));
574
+ debouncedForwardReload();
575
+ return;
576
+ }
577
+ // Admin endpoint: grow the pod pool at runtime (gateway autoscaling).
578
+ if (req.method === 'POST' && req.url === '/webpack/scale') {
579
+ let body = '';
580
+ req.on('data', (c) => { body += c; });
581
+ req.on('end', async () => {
582
+ let pods = 0;
583
+ try {
584
+ pods = Number(JSON.parse(body || '{}').pods) || 0;
585
+ }
586
+ catch { /* bad json */ }
587
+ pods = Math.max(1, Math.min(pods, 16));
588
+ await ensurePoolSize(pods);
589
+ res.writeHead(200, { 'content-type': 'application/json' });
590
+ res.end(JSON.stringify({ ok: true, pods }));
591
+ });
592
+ return;
593
+ }
594
+ // Front-gateway routes: host/prefix match → external target, or fall
595
+ // through to this service's own children when the route has no target.
596
+ const route = matchGatewayRoute(req);
597
+ if (route?.prependPrefix && !req.url?.startsWith(route.prependPrefix)) {
598
+ req.url = route.prependPrefix + (req.url ?? '/');
599
+ }
600
+ if (route?.target) {
601
+ applyGatewayMiddleware(req);
602
+ proxy.web(req, res, { target: route.target, autoRewrite: true, changeOrigin: true }, (err) => {
603
+ console.error(`[parent] proxy error to ${route.target}:`, err);
604
+ res.statusCode = 502;
605
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
606
+ res.end(`Upstream error from ${route.target}.`);
607
+ });
608
+ return;
609
+ }
610
+ // Enforce service prefix (mimics production gateway)
611
+ if (SERVICE_PREFIX) {
612
+ if (!req.url?.startsWith(SERVICE_PREFIX)) {
613
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
614
+ res.end(`Not found. This service is mounted at ${SERVICE_PREFIX}/`);
615
+ return;
616
+ }
617
+ req.url = req.url.slice(SERVICE_PREFIX.length) || '/';
618
+ }
619
+ // Gateway middleware: user-provided plugin modifies headers before proxying
620
+ applyGatewayMiddleware(req);
621
+ // Proxy all other traffic using round-robin among healthy children
622
+ const targetChild = pickChild();
623
+ if (!targetChild) {
624
+ res.statusCode = 503;
625
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
626
+ const errs = children.map(c => `#${c.id}: ${c.lastError ?? 'no error recorded'}`).join('\n');
627
+ res.end(`No healthy children available. POST /webpack/reload to recover.\n\nLast errors:\n${errs}`);
628
+ return;
629
+ }
630
+ proxy.web(req, res, {
631
+ target: { socketPath: targetChild.socketPath },
632
+ autoRewrite: true,
633
+ headers: {
634
+ 'x-proxy-child-id': String(targetChild.id) // for logging/debugging
635
+ }
636
+ }, (err) => {
637
+ console.error(`[parent] proxy error to Child#${targetChild.id}:`, err);
638
+ // Mark this child unhealthy so next request won't pick it
639
+ targetChild.healthy = false;
640
+ targetChild.lastError = String(err?.stack || err);
641
+ res.statusCode = 502;
642
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
643
+ res.end(`Upstream error from Child#${targetChild.id}. Try /webpack/reload.\n${targetChild.lastError}`);
644
+ });
645
+ };
646
+ // Optional HTTPS, @vitejs/plugin-basic-ssl style: a cached self-signed cert
647
+ // (SANs: localhost + loopbacks + every configured route hostname) so
648
+ // https://*.localhost dev "just works" after a one-time browser trust.
649
+ let server;
650
+ let gatewayPem;
651
+ let gatewayCaPath;
652
+ const extraServers = [];
653
+ if (GATEWAY_HTTPS) {
654
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
655
+ const basicSsl = require('./basic-ssl');
656
+ const certDomains = GATEWAY_ROUTES.map((r) => r.host).filter((h) => !!h);
657
+ const certCacheDir = path.join(process.cwd(), 'node_modules', '.cache', 'davnx-webpack');
658
+ gatewayPem = basicSsl.getCertificateSync(certCacheDir, SERVICE_NAME || 'davnx.dev', certDomains);
659
+ gatewayCaPath = basicSsl.caCertPath(certCacheDir);
660
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
661
+ const httpsMod = require('node:https');
662
+ server = httpsMod.createServer({ key: gatewayPem, cert: gatewayPem }, requestHandler);
663
+ }
664
+ else {
665
+ server = http.createServer(requestHandler);
666
+ }
667
+ // WebSocket upgrades (vite HMR, app sockets): same routing as requests —
668
+ // external target when a route matches, otherwise a healthy child socket.
669
+ const upgradeHandler = (req, socket, head) => {
670
+ const route = matchGatewayRoute(req);
671
+ if (route?.prependPrefix && !req.url?.startsWith(route.prependPrefix)) {
672
+ req.url = route.prependPrefix + (req.url ?? '/');
673
+ }
674
+ if (route?.target) {
675
+ proxy.ws(req, socket, head, { target: route.target, changeOrigin: true }, () => socket.destroy());
676
+ return;
677
+ }
678
+ const targetChild = pickChild();
679
+ if (!targetChild) {
680
+ socket.destroy();
681
+ return;
682
+ }
683
+ proxy.ws(req, socket, head, { target: { socketPath: targetChild.socketPath } }, () => socket.destroy());
684
+ };
685
+ server.on('upgrade', upgradeHandler);
686
+ server.listen(publicPort, async () => {
687
+ const scheme = GATEWAY_HTTPS ? 'https' : 'http';
688
+ console.log(`[parent] Listening on ${scheme}://localhost:${publicPort}`);
689
+ if (SERVICE_PREFIX) {
690
+ console.log(`[parent] Service prefix: "${SERVICE_PREFIX}" (enforced — requests without it will get 404)`);
691
+ }
692
+ if (gatewayMiddleware) {
693
+ console.log(`[parent] Gateway middleware: ACTIVE (${GATEWAY_MIDDLEWARE_PATH})`);
694
+ }
695
+ if (GATEWAY_ROUTES.length > 0) {
696
+ console.log(`[parent] Gateway routes: ${GATEWAY_ROUTES.map((r) => `${r.host ?? '*'}${r.prefix ?? '/'}→${r.target ?? 'children'}`).join(' ')}`);
697
+ }
698
+ if (gatewayCaPath) {
699
+ console.log(`[parent] HTTPS dev CA (trust ONCE, then rotations/new hostnames stay green):`);
700
+ console.log(`[parent] sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ${gatewayCaPath}`);
701
+ }
702
+ console.log(`POST ${scheme}://localhost:${publicPort}/webpack/reload to trigger rolling swap/respawn`);
703
+ await ensurePoolSize(CHILD_COUNT);
704
+ for (const name of WORKER_NAMES) {
705
+ spawnWorkerProcess(name);
706
+ }
707
+ });
708
+ // Internal service-to-service port: plain http, straight to the children —
709
+ // NO gateway routes, NO gateway middleware (JWT etc.). Peers call this
710
+ // directly (http://localhost:<internalPort>/...) instead of going through
711
+ // the public front door, mirroring cluster-internal networking in prod.
712
+ const INTERNAL_PORT = Number(process.env.INTERNAL_PORT || 0);
713
+ if (INTERNAL_PORT > 0) {
714
+ const internalHandler = (req, res) => {
715
+ const targetChild = pickChild();
716
+ if (!targetChild) {
717
+ res.statusCode = 503;
718
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
719
+ res.end('No healthy children available.');
720
+ return;
721
+ }
722
+ proxy.web(req, res, {
723
+ target: { socketPath: targetChild.socketPath },
724
+ autoRewrite: true,
725
+ }, (err) => {
726
+ targetChild.healthy = false;
727
+ targetChild.lastError = String(err?.stack || err);
728
+ res.statusCode = 502;
729
+ res.end(`Upstream error from Child#${targetChild.id}.`);
730
+ });
731
+ };
732
+ const internal = http.createServer(internalHandler);
733
+ internal.on('upgrade', (req, socket, head) => {
734
+ const targetChild = pickChild();
735
+ if (!targetChild) {
736
+ socket.destroy();
737
+ return;
738
+ }
739
+ proxy.ws(req, socket, head, { target: { socketPath: targetChild.socketPath } }, () => socket.destroy());
740
+ });
741
+ internal.on('error', (err) => {
742
+ console.warn(`[parent] internal port ${INTERNAL_PORT} unavailable (${err.code}).`);
743
+ });
744
+ internal.listen(INTERNAL_PORT, () => {
745
+ console.log(`[parent] Internal (service-to-service, no gateway middleware) on http://localhost:${INTERNAL_PORT}`);
746
+ });
747
+ extraServers.push(internal);
748
+ }
749
+ // Extra listener ports sharing the SAME handler + upgrade routing. The
750
+ // point is pretty dev domains: with `gateway.https` + extraPorts [443],
751
+ // https://tetros.ai.local/ works with no :port in the URL (macOS lets
752
+ // unprivileged processes bind <1024; elsewhere we warn and carry on).
753
+ const EXTRA_PORTS = (() => {
754
+ try {
755
+ return JSON.parse(process.env.GATEWAY_EXTRA_PORTS || '[]');
756
+ }
757
+ catch {
758
+ return [];
759
+ }
760
+ })();
761
+ for (const extraPort of EXTRA_PORTS) {
762
+ if (!Number.isInteger(extraPort) || extraPort === Number(publicPort))
763
+ continue;
764
+ const extra = GATEWAY_HTTPS
765
+ ? // eslint-disable-next-line @typescript-eslint/no-require-imports
766
+ require('node:https').createServer({ key: gatewayPem, cert: gatewayPem }, requestHandler)
767
+ : http.createServer(requestHandler);
768
+ extra.on('upgrade', upgradeHandler);
769
+ extra.on('error', (err) => {
770
+ console.warn(`[parent] extra port ${extraPort} unavailable (${err.code}) — main port still up.`);
771
+ });
772
+ extra.listen(extraPort, () => {
773
+ const scheme = GATEWAY_HTTPS ? 'https' : 'http';
774
+ console.log(`[parent] Also listening on ${scheme}://localhost:${extraPort}`);
775
+ });
776
+ extraServers.push(extra);
777
+ }
778
+ // Graceful shutdown of parent (children get SIGTERM)
779
+ const shutdown = async (sig) => {
780
+ shuttingDown = true; // stop crash-respawn from fighting the teardown
781
+ console.log(`[${sig}] [parent] shutting down…`);
782
+ for (const extra of extraServers) {
783
+ try {
784
+ extra.close();
785
+ }
786
+ catch { /* ignore */ }
787
+ }
788
+ for (const w of workerProcesses) {
789
+ try {
790
+ w.proc.kill('SIGTERM');
791
+ }
792
+ catch { }
793
+ }
794
+ for (const c of children) {
795
+ try {
796
+ c.proc.kill('SIGTERM');
797
+ }
798
+ catch { /* ignore */
799
+ }
800
+ if (c.socketPath)
801
+ cleanupSock(c.socketPath);
802
+ }
803
+ // Clean up socket directory
804
+ try {
805
+ fs.rmdirSync(SOCK_DIR);
806
+ }
807
+ catch { }
808
+ setTimeout(() => process.exit(0), 700).unref();
809
+ };
810
+ ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT', 'SIGUSR2'].forEach(sig => process.on(sig, () => void shutdown(sig)));
811
+ })();
812
+ }