@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.
- package/LICENSE +202 -0
- package/README.md +130 -0
- package/basic-ssl.d.ts +7 -0
- package/basic-ssl.d.ts.map +1 -0
- package/basic-ssl.js +158 -0
- package/build-scope-patterns.d.ts +12 -0
- package/build-scope-patterns.d.ts.map +1 -0
- package/build-scope-patterns.js +40 -0
- package/create-webpack-dev.d.ts +27 -0
- package/create-webpack-dev.d.ts.map +1 -0
- package/create-webpack-dev.js +151 -0
- package/create-webpack-prod.d.ts +28 -0
- package/create-webpack-prod.d.ts.map +1 -0
- package/create-webpack-prod.js +169 -0
- package/executors/build/build.impl.d.ts +19 -0
- package/executors/build/build.impl.d.ts.map +1 -0
- package/executors/build/build.impl.js +98 -0
- package/executors/build/schema.d.ts +35 -0
- package/executors/build/schema.json +135 -0
- package/executors/gateway/gateway.impl.d.ts +23 -0
- package/executors/gateway/gateway.impl.d.ts.map +1 -0
- package/executors/gateway/gateway.impl.js +39 -0
- package/executors/gateway/gateway.lib.d.ts +130 -0
- package/executors/gateway/gateway.lib.d.ts.map +1 -0
- package/executors/gateway/gateway.lib.js +797 -0
- package/executors/gateway/jwks-verify.d.ts +28 -0
- package/executors/gateway/jwks-verify.d.ts.map +1 -0
- package/executors/gateway/jwks-verify.js +121 -0
- package/executors/gateway/main.prod-gateway.d.ts +2 -0
- package/executors/gateway/main.prod-gateway.d.ts.map +1 -0
- package/executors/gateway/main.prod-gateway.js +290 -0
- package/executors/gateway/main.rollout.d.ts +2 -0
- package/executors/gateway/main.rollout.d.ts.map +1 -0
- package/executors/gateway/main.rollout.js +130 -0
- package/executors/gateway/manifest.d.ts +275 -0
- package/executors/gateway/manifest.d.ts.map +1 -0
- package/executors/gateway/manifest.js +344 -0
- package/executors/gateway/prod-gateway.lib.d.ts +58 -0
- package/executors/gateway/prod-gateway.lib.d.ts.map +1 -0
- package/executors/gateway/prod-gateway.lib.js +535 -0
- package/executors/gateway/providers-prod.d.ts +46 -0
- package/executors/gateway/providers-prod.d.ts.map +1 -0
- package/executors/gateway/providers-prod.js +199 -0
- package/executors/gateway/registry-source.d.ts +68 -0
- package/executors/gateway/registry-source.d.ts.map +1 -0
- package/executors/gateway/registry-source.js +131 -0
- package/executors/gateway/rollout-ops.d.ts +54 -0
- package/executors/gateway/rollout-ops.d.ts.map +1 -0
- package/executors/gateway/rollout-ops.js +167 -0
- package/executors/gateway/schema.d.ts +10 -0
- package/executors/gateway/schema.json +30 -0
- package/executors/serve/schema.d.ts +53 -0
- package/executors/serve/schema.json +196 -0
- package/executors/serve/serve.impl.d.ts +25 -0
- package/executors/serve/serve.impl.d.ts.map +1 -0
- package/executors/serve/serve.impl.js +243 -0
- package/executors/trust/schema.d.ts +6 -0
- package/executors/trust/schema.json +20 -0
- package/executors/trust/trust.impl.d.ts +42 -0
- package/executors/trust/trust.impl.d.ts.map +1 -0
- package/executors/trust/trust.impl.js +126 -0
- package/executors.json +24 -0
- package/index.d.ts +4 -0
- package/index.d.ts.map +1 -0
- package/index.js +9 -0
- package/main.devserver.d.ts +16 -0
- package/main.devserver.d.ts.map +1 -0
- package/main.devserver.js +812 -0
- 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
|
+
}
|