@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,797 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hostMatches = void 0;
4
+ exports.verifyDevJwt = verifyDevJwt;
5
+ exports.stampIdentity = stampIdentity;
6
+ exports.pickSock = pickSock;
7
+ exports.discoverRoutes = discoverRoutes;
8
+ exports.startGateway = startGateway;
9
+ const tslib_1 = require("tslib");
10
+ /**
11
+ * The gateway runtime, separated from the Nx executor so it is directly
12
+ * testable: `startGateway()` returns a handle with the actually-bound ports
13
+ * and a `stop()` — no signals needed. The executor is a thin wrapper.
14
+ */
15
+ const node_child_process_1 = require("node:child_process");
16
+ const node_crypto_1 = require("node:crypto");
17
+ const fs = tslib_1.__importStar(require("node:fs"));
18
+ const http = tslib_1.__importStar(require("node:http"));
19
+ const os = tslib_1.__importStar(require("node:os"));
20
+ const path = tslib_1.__importStar(require("node:path"));
21
+ const manifest_1 = require("./manifest");
22
+ var manifest_2 = require("./manifest"); // back-compat re-export
23
+ Object.defineProperty(exports, "hostMatches", { enumerable: true, get: function () { return manifest_2.hostMatches; } });
24
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
25
+ const httpProxy = require('http-proxy');
26
+ /**
27
+ * Dev edge auth (HS256), mirroring the prod gateway: verify the end-user token, then stamp the
28
+ * trusted identity headers the services read (via GatewayTrustGuard). RS256/JWKS is prod's job;
29
+ * dev shares the `AUTH_JWT_SECRET` the auth host signs with. Returns the claims, or undefined.
30
+ */
31
+ function verifyDevJwt(req) {
32
+ const secret = process.env['AUTH_JWT_SECRET'] ?? 'tetros-dev-secret';
33
+ const auth = String((Array.isArray(req.headers['authorization']) ? req.headers['authorization'][0] : req.headers['authorization']) ?? '');
34
+ if (!auth.startsWith('Bearer '))
35
+ return undefined;
36
+ const [h, p, s] = auth.slice(7).split('.');
37
+ if (!h || !p || !s)
38
+ return undefined;
39
+ const expected = (0, node_crypto_1.createHmac)('sha256', secret).update(`${h}.${p}`).digest('base64url');
40
+ const a = Buffer.from(s);
41
+ const b = Buffer.from(expected);
42
+ if (a.length !== b.length || !(0, node_crypto_1.timingSafeEqual)(a, b))
43
+ return undefined;
44
+ try {
45
+ const claims = JSON.parse(Buffer.from(p, 'base64url').toString());
46
+ if (typeof claims['exp'] === 'number' && Date.now() / 1000 > claims['exp'])
47
+ return undefined;
48
+ return claims;
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ /** Stamp verified identity onto the forwarded request — tenant from the `tid` CLAIM, never a client header. */
55
+ function stampIdentity(req, claims) {
56
+ const set = (k, v) => { if (v !== undefined && v !== null && v !== '')
57
+ req.headers[k] = String(v); };
58
+ set('x-user-id', claims['sub']);
59
+ set('x-tenant-id', claims['tid']);
60
+ const roles = claims['roles'];
61
+ if (Array.isArray(roles) && roles.length)
62
+ req.headers['x-user-roles'] = roles.map(String).join(',');
63
+ const perms = claims['perms'];
64
+ if (Array.isArray(perms) && perms.length)
65
+ req.headers['x-user-permissions'] = perms.map(String).join(',');
66
+ }
67
+ const devAttrs = (req, claims) => ({
68
+ ...claims,
69
+ ip: req.socket.remoteAddress ?? '',
70
+ method: (req.method ?? 'GET').toUpperCase(),
71
+ path: (req.url ?? '/').split('?')[0],
72
+ });
73
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
74
+ /** Round-robin over the pool's sockets, rescanning the dir when the cache ages out. */
75
+ function pickSock(pool, scanTtlMs = 1500) {
76
+ const now = Date.now();
77
+ if (now - pool.scannedAt > scanTtlMs) {
78
+ try {
79
+ pool.socks = fs.readdirSync(pool.dir)
80
+ .filter((f) => /^child-\d+\.sock$/.test(f))
81
+ .map((f) => path.join(pool.dir, f));
82
+ }
83
+ catch {
84
+ pool.socks = [];
85
+ }
86
+ pool.scannedAt = now;
87
+ }
88
+ if (pool.socks.length === 0)
89
+ return undefined;
90
+ pool.idx = (pool.idx + 1) % pool.socks.length;
91
+ return pool.socks[pool.idx];
92
+ }
93
+ /**
94
+ * Read every project's `cluster` (or legacy `davnx`) declaration and build
95
+ * the route table: host routes first (plus auto `internal.<host>` for pod
96
+ * services), the `default` catch-all last.
97
+ */
98
+ function discoverRoutes(rootDir, projects, sockBaseDir = os.tmpdir()) {
99
+ const routes = [];
100
+ const services = [];
101
+ let defaultRoute;
102
+ for (const [name, p] of Object.entries(projects)) {
103
+ let decl;
104
+ try {
105
+ const raw = JSON.parse(fs.readFileSync(path.join(rootDir, p.root, 'project.json'), 'utf8'));
106
+ decl = raw.cluster ?? raw.davnx;
107
+ }
108
+ catch {
109
+ continue;
110
+ }
111
+ if (!decl)
112
+ continue;
113
+ let pool;
114
+ let svc;
115
+ let target;
116
+ if (decl.service) {
117
+ pool = { dir: path.join(sockBaseDir, `${decl.service}-devserver`), socks: [], idx: -1, scannedAt: 0 };
118
+ svc = { project: name, decl, pool, state: 'down', lastUsed: Date.now(), inflight: 0, lastScaleAt: 0 };
119
+ services.push(svc);
120
+ }
121
+ else {
122
+ target = decl.upstream ?? (decl.port ? `http://localhost:${decl.port}` : undefined);
123
+ if (!target) {
124
+ console.warn(`[gateway] ${name}: no service/upstream/port — skipped.`);
125
+ continue;
126
+ }
127
+ }
128
+ const base = { project: name, prependPrefix: decl.prependPrefix, target, pool, svc, prefix: decl.path, auth: decl.auth };
129
+ if (decl.host) {
130
+ routes.push({ ...base, host: decl.host });
131
+ // internal.<host>: east-west traffic, same pods, NO middleware.
132
+ if (pool && decl.internalHost !== false) {
133
+ routes.push({
134
+ ...base,
135
+ host: typeof decl.internalHost === 'string' ? decl.internalHost : `internal.${decl.host}`,
136
+ internal: true,
137
+ });
138
+ }
139
+ }
140
+ if (decl.default)
141
+ defaultRoute = { ...base, prefix: undefined };
142
+ }
143
+ if (defaultRoute)
144
+ routes.push(defaultRoute);
145
+ // specificity sort (shared with prod): host+path beats host-only beats
146
+ // catch-all — so a path-mounted service (api.x/channels) wins over the
147
+ // host's owner (api.x → api).
148
+ (0, manifest_1.sortBySpecificity)(routes);
149
+ return { routes, services };
150
+ }
151
+ async function startGateway(options, context) {
152
+ const https = options.https !== false;
153
+ const ports = options.ports?.length ? options.ports : [443];
154
+ const scanTtlMs = options.scanTtlMs ?? 1500;
155
+ const startupTimeoutMs = options.startupTimeoutMs ?? 120_000;
156
+ // Set by stop(); consulted by spawnService/ensureUp so an in-flight cold
157
+ // start neither spawns a process nor logs after the gateway is torn down.
158
+ let stopped = false;
159
+ // --- LensMCP bus: the gateway is a first-class lens source -------------------
160
+ // Appends BaseEvent-shaped lines to the shared event file the MCP tails
161
+ // (same rendezvous the vite plugin and nest instrumentation use), so the
162
+ // lens sees the cluster itself: services up/down, cold starts, autoscale,
163
+ // idle kills, and aggregated host→service traffic edges for the topology.
164
+ const eventFile = process.env['LENSMCP_EVENT_FILE'] ?? path.join(context.root, '.lensmcp', 'events.jsonl');
165
+ const eid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 12);
166
+ const emit = (severity, title, fingerprint, raw, flowCtx) => {
167
+ try {
168
+ fs.mkdirSync(path.dirname(eventFile), { recursive: true });
169
+ fs.appendFileSync(eventFile, JSON.stringify({
170
+ id: eid(), sessionId: 'pending', timestamp: Date.now(),
171
+ source: 'gateway', category: 'cluster', severity,
172
+ context: { sessionId: 'pending', ...flowCtx }, fingerprint, title, raw,
173
+ }) + '\n');
174
+ }
175
+ catch { /* lens offline — never block traffic */ }
176
+ };
177
+ const hdr = (v) => typeof v === 'string' && v !== '' ? v : Array.isArray(v) ? v[0] : undefined;
178
+ const beginTrace = (req) => {
179
+ const flowId = hdr(req.headers['x-lensmcp-flow-id']);
180
+ let requestId = hdr(req.headers['x-request-id']);
181
+ if (!flowId && !requestId && !hdr(req.headers['traceparent']))
182
+ return undefined;
183
+ if (!requestId) {
184
+ // Mint the request id AT THE EDGE and inject it downstream, so the
185
+ // gateway trace and the pod's server-request/spans share one id —
186
+ // that's the join key that attributes pod events to the routed service.
187
+ requestId = eid();
188
+ req.headers['x-request-id'] = requestId;
189
+ }
190
+ return {
191
+ startedAt: Date.now(),
192
+ method: (req.method ?? 'GET').toUpperCase(),
193
+ host: (req.headers.host || '').split(':')[0],
194
+ url: req.url ?? '/',
195
+ steps: [],
196
+ ...(flowId ? { flowId } : {}),
197
+ ...(requestId ? { requestId } : {}),
198
+ ...(hdr(req.headers['x-lensmcp-origin-node-id']) ? { originNodeId: hdr(req.headers['x-lensmcp-origin-node-id']) } : {}),
199
+ };
200
+ };
201
+ const traceStep = (t, stepName, info) => {
202
+ if (!t)
203
+ return;
204
+ t.steps.push({ step: stepName, atMs: Date.now() - t.startedAt, ...info });
205
+ };
206
+ const finishTrace = (t, project, status, extra) => {
207
+ if (!t || t.done)
208
+ return;
209
+ t.done = true;
210
+ const durationMs = Date.now() - t.startedAt;
211
+ emit(status >= 500 ? 'error' : 'info', `gateway ${t.method} ${t.host}${t.url} → ${status} (${durationMs}ms)`, `gateway-request:${project}`, {
212
+ kind: 'gateway-request', project, host: t.host, method: t.method, url: t.url,
213
+ status, durationMs, startedAt: t.startedAt, steps: t.steps, ...extra,
214
+ }, {
215
+ ...(t.flowId ? { flowId: t.flowId } : {}),
216
+ ...(t.requestId ? { requestId: t.requestId } : {}),
217
+ ...(t.originNodeId ? { originNodeId: t.originNodeId } : {}),
218
+ });
219
+ };
220
+ // --- discover ---------------------------------------------------------------
221
+ const { routes, services } = discoverRoutes(context.root, context.projectsConfigurations?.projects ?? {});
222
+ if (routes.length === 0) {
223
+ throw new Error('[gateway] no project declares a `cluster` field — nothing to route.');
224
+ }
225
+ // --- service lifecycle: scale-from-zero, autoscale, idle kill ----------------
226
+ const scanNow = (pool) => { pool.scannedAt = 0; return pickSock(pool, scanTtlMs) ? pool.socks.length : 0; };
227
+ const spawnService = (svc) => {
228
+ if (stopped)
229
+ return; // never spawn a process after the gateway is told to stop
230
+ const target = `${svc.project}:${svc.decl.serveTarget ?? 'serve-hmr'}`;
231
+ const min = Math.max(1, svc.decl.minPods ?? 1);
232
+ console.log(`[gateway] starting ${target} (pods=${min})…`);
233
+ emit('info', `service starting: ${svc.project} (${min} pod${min > 1 ? 's' : ''})`, `cluster-service:${svc.project}`, { kind: 'service-starting', project: svc.project, pods: min });
234
+ svc.state = 'starting';
235
+ svc.spawnAt = Date.now();
236
+ // childCount goes as a CLI option, NOT env: nx executes tasks via its
237
+ // daemon, whose environment is not the gateway's — env vars get lost.
238
+ const proc = (0, node_child_process_1.spawn)('yarn', ['nx', 'run', target, `--childCount=${min}`], {
239
+ cwd: context.root,
240
+ detached: true,
241
+ // NX_DAEMON=false keeps the serve task IN this spawned process group.
242
+ // With the daemon on, Nx hands the long-running task to the persistent
243
+ // daemon (a DIFFERENT group), so the later process-group kill reaps only
244
+ // the `yarn nx` shim and orphans the devserver + pod tree — the classic
245
+ // "idle-kill leaves live pods" leak. (--childCount as a CLI arg already
246
+ // avoids the env-loss the daemon was relied on for.)
247
+ env: { ...process.env, NX_DAEMON: 'false' },
248
+ stdio: ['ignore', 'pipe', 'pipe'],
249
+ });
250
+ svc.lastErrors = [];
251
+ svc.buildFailed = false;
252
+ const sniff = (d) => {
253
+ const text = d.toString();
254
+ for (const line of text.split('\n')) {
255
+ if (/ERROR in |error TS\d+|Found \d+ error|Unhandled 'error' event|EADDRINUSE/.test(line)) {
256
+ svc.lastErrors.push(line.replace(/\x1b\[[0-9;]*m/g, '').trim());
257
+ if (svc.lastErrors.length > 12)
258
+ svc.lastErrors.shift();
259
+ svc.buildFailed = true;
260
+ emit('error', `service error: ${svc.project}`, `cluster-service:${svc.project}`, { kind: 'service-error', project: svc.project, line: svc.lastErrors[svc.lastErrors.length - 1] });
261
+ }
262
+ if (/No typescript errors found|webpack compiled successfully|Child#\d+ ready/.test(line))
263
+ svc.buildFailed = false;
264
+ }
265
+ };
266
+ proc.stdout?.on('data', (d) => { if (stopped)
267
+ return; sniff(d); process.stdout.write(`[${svc.project}] ${d}`); });
268
+ proc.stderr?.on('data', (d) => { if (stopped)
269
+ return; sniff(d); process.stderr.write(`[${svc.project}] ${d}`); });
270
+ proc.on('exit', (code) => {
271
+ svc.proc = undefined;
272
+ svc.state = 'down';
273
+ if (stopped)
274
+ return; // gateway is tearing down — its dying services' exit chatter is noise
275
+ console.log(`[gateway] ${target} exited (${code})`);
276
+ emit(code === 0 ? 'info' : 'warning', `service down: ${svc.project} (exit ${code})`, `cluster-service:${svc.project}`, { kind: 'service-down', project: svc.project, code });
277
+ });
278
+ svc.proc = proc;
279
+ };
280
+ const ensureUp = async (svc) => {
281
+ if (scanNow(svc.pool) > 0) {
282
+ if (svc.state !== 'up') {
283
+ emit('info', `service up: ${svc.project} (${svc.pool.socks.length} pods, adopted)`, `cluster-service:${svc.project}`, { kind: 'service-up', project: svc.project, pods: svc.pool.socks.length, adopted: true });
284
+ }
285
+ svc.state = 'up';
286
+ return true;
287
+ }
288
+ if (stopped)
289
+ return false;
290
+ if (svc.state !== 'starting' && !svc.proc)
291
+ spawnService(svc); // a live spawn supervises itself — don't stack a second one (EADDRINUSE)
292
+ const deadline = Date.now() + startupTimeoutMs;
293
+ const failFastAt = Date.now() + 12_000; // a build error within 12s → stop waiting, report the cause
294
+ while (!stopped && Date.now() < deadline) {
295
+ await sleep(Math.min(700, startupTimeoutMs / 4));
296
+ if (svc.buildFailed && Date.now() > failFastAt)
297
+ return false;
298
+ if (scanNow(svc.pool) > 0) {
299
+ svc.state = 'up';
300
+ svc.buildFailed = false;
301
+ const durationMs = svc.spawnAt ? Date.now() - svc.spawnAt : undefined;
302
+ console.log(`[gateway] ${svc.project} up (${svc.pool.socks.length} pods${durationMs ? `, cold start ${durationMs}ms` : ''})`);
303
+ emit('info', `cold start: ${svc.project} (${svc.pool.socks.length} pods, ${durationMs ?? '?'}ms)`, `cluster-service:${svc.project}`, { kind: 'cold-start', project: svc.project, pods: svc.pool.socks.length, durationMs });
304
+ return true;
305
+ }
306
+ if (svc.state === 'down')
307
+ return false; // spawn died
308
+ }
309
+ return false;
310
+ };
311
+ const scaleUp = (svc) => {
312
+ const max = svc.decl.maxPods ?? svc.decl.minPods ?? 1;
313
+ const cur = svc.pool.socks.length;
314
+ const admin = svc.decl.adminUrl ?? (svc.decl.port ? `http://localhost:${svc.decl.port}` : undefined);
315
+ if (!admin || cur === 0 || cur >= max || svc.inflight <= cur)
316
+ return;
317
+ if (Date.now() - svc.lastScaleAt < 5000)
318
+ return;
319
+ svc.lastScaleAt = Date.now();
320
+ const mod = admin.startsWith('https')
321
+ ? // eslint-disable-next-line @typescript-eslint/no-require-imports
322
+ require('node:https')
323
+ : http;
324
+ const r = mod.request(`${admin}/webpack/scale`, { method: 'POST', rejectUnauthorized: false }, (rs) => rs.resume());
325
+ r.on('error', () => undefined);
326
+ r.end(JSON.stringify({ pods: cur + 1 }));
327
+ console.log(`[gateway] autoscale ${svc.project}: ${cur} → ${cur + 1} pods (inflight ${svc.inflight})`);
328
+ emit('info', `autoscale: ${svc.project} ${cur} → ${cur + 1} pods`, `cluster-service:${svc.project}`, { kind: 'autoscale', project: svc.project, from: cur, to: cur + 1, inflight: svc.inflight });
329
+ };
330
+ // Idle sweeper: kill gateway-spawned lazy services after idleKillSec.
331
+ const killSpawned = (svc, why) => {
332
+ if (!svc.proc)
333
+ return;
334
+ console.log(`[gateway] ${svc.project} ${why} — scaling to zero.`);
335
+ emit('info', `idle kill: ${svc.project} (${why})`, `cluster-service:${svc.project}`, { kind: 'idle-kill', project: svc.project, why });
336
+ const dying = svc.proc;
337
+ // Detach the corpse's handlers BEFORE killing: a dying webpack/nx flushes
338
+ // buffered stdout/stderr as it exits, and those `sniff` callbacks would
339
+ // keep mutating svc.lastErrors/buildFailed (phantom build failures) — fatal
340
+ // if a fresh request re-spawns this same svc while the corpse still flushes.
341
+ try {
342
+ dying.stdout?.removeAllListeners('data');
343
+ dying.stderr?.removeAllListeners('data');
344
+ dying.removeAllListeners('exit');
345
+ }
346
+ catch { /* ignore */ }
347
+ try {
348
+ process.kill(-dying.pid, 'SIGTERM');
349
+ }
350
+ catch {
351
+ try {
352
+ dying.kill('SIGTERM');
353
+ }
354
+ catch { /* gone */ }
355
+ }
356
+ svc.proc = undefined;
357
+ svc.state = 'down';
358
+ // children die with the tree but their sock FILES survive — remove them
359
+ // so the canvas and the proxy never see ghost pods.
360
+ setTimeout(() => {
361
+ try {
362
+ for (const f of fs.readdirSync(svc.pool.dir)) {
363
+ if (f.endsWith('.sock'))
364
+ fs.unlinkSync(path.join(svc.pool.dir, f));
365
+ }
366
+ }
367
+ catch { /* gone */ }
368
+ svc.pool.socks = [];
369
+ svc.pool.scannedAt = 0;
370
+ }, 1200).unref?.();
371
+ };
372
+ const sweeper = setInterval(() => {
373
+ for (const svc of services) {
374
+ const idle = svc.decl.idleKillSec;
375
+ if (!svc.proc || !idle || svc.inflight > 0)
376
+ continue;
377
+ if (Date.now() - svc.lastUsed > idle * 1000)
378
+ killSpawned(svc, `idle ${idle}s`);
379
+ }
380
+ }, options.sweepMs ?? 10_000);
381
+ sweeper.unref();
382
+ // Eager services boot with the gateway.
383
+ for (const svc of services) {
384
+ if (svc.decl.eager)
385
+ void ensureUp(svc);
386
+ }
387
+ // --- per-service api keys (be-security: the gateway is the trust boundary) --
388
+ // Every pod service gets a stable key in {cacheDir}/service-keys.json.
389
+ // Public routes: the gateway injects the TARGET's key as x-internal-token —
390
+ // pods are only reachable through it (unix sockets aren't on the network).
391
+ // internal.<host> routes: the CALLER must present its own key as x-api-key;
392
+ // the gateway validates it, stamps x-api-key-id (caller identity, for audit
393
+ // and lens traces) and forwards with the target's x-internal-token.
394
+ const keysDir = path.join(context.root, 'node_modules', '.cache', 'davnx-webpack');
395
+ const keysPath = path.join(keysDir, 'service-keys.json');
396
+ let serviceKeys = {};
397
+ try {
398
+ serviceKeys = JSON.parse(fs.readFileSync(keysPath, 'utf8'));
399
+ }
400
+ catch { /* first run */ }
401
+ let keysDirty = false;
402
+ for (const svc of services) {
403
+ if (!serviceKeys[svc.project]) {
404
+ serviceKeys[svc.project] = (0, node_crypto_1.randomBytes)(16).toString('hex');
405
+ keysDirty = true;
406
+ }
407
+ }
408
+ if (keysDirty) {
409
+ try {
410
+ fs.mkdirSync(keysDir, { recursive: true });
411
+ fs.writeFileSync(keysPath, JSON.stringify(serviceKeys, null, 2));
412
+ }
413
+ catch { /* ro fs */ }
414
+ }
415
+ const keyToProject = new Map(Object.entries(serviceKeys).map(([p, k]) => [k, p]));
416
+ // --- middleware (the JWT seam; internal.* routes skip it) -------------------
417
+ let middleware;
418
+ if (options.middleware) {
419
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
420
+ const mod = require(path.resolve(context.root, options.middleware));
421
+ middleware = (mod.default ?? mod);
422
+ }
423
+ // --- routing ------------------------------------------------------------------
424
+ // The matcher is SHARED with the production gateway (one routing brain, no
425
+ // drift — see manifest.ts `matchRoute` for the rules). Dev's Route carries
426
+ // pod/svc fields too; matchRoute only reads host/prefix.
427
+ const match = (req) => (0, manifest_1.matchRoute)(routes, (req.headers.host || '').split(':')[0], req.url ?? '/');
428
+ // secure:false — upstreams may terminate TLS with the same local CA.
429
+ // proxyTimeout — a pod that accepts the socket but never responds would
430
+ // otherwise pin svc.inflight forever (blocking idle-kill, skewing autoscale)
431
+ // and leave the gateway-request trace open; the timeout fires the proxy
432
+ // error path → 502 → res 'close' → inflight decrements + trace finishes.
433
+ // Keep-alive to upstreams (pods over unix sockets AND TCP apps). Without an
434
+ // agent http-proxy opens a fresh connection per request — a connect per call
435
+ // that ~halves throughput and adds latency. One pooled agent reuses sockets
436
+ // (keyed by socketPath / host:port); https targets get the verify-skipping
437
+ // https agent. Picked per request by target in `forward`/`upgrade`.
438
+ // NB: a local `const https` (TLS-on flag) shadows the module here, so the
439
+ // https agent is built via require — matching this file's node:https usage.
440
+ const agentOpts = { keepAlive: true, keepAliveMsecs: 30_000, maxSockets: 1024, maxFreeSockets: 256 };
441
+ const httpAgent = new http.Agent(agentOpts);
442
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
443
+ const httpsAgent = new (require('node:https').Agent)(agentOpts);
444
+ const agentForTarget = (target) => typeof target === 'string' && target.startsWith('https:') ? httpsAgent : httpAgent;
445
+ const proxy = httpProxy.createProxyServer({ xfwd: true, secure: false, proxyTimeout: 120_000, agent: httpAgent });
446
+ // Surface WHICH pod served the request (round-robin made visible).
447
+ proxy.on('proxyRes', (proxyRes, req) => {
448
+ const pod = req.__lensmcpPod;
449
+ if (pod)
450
+ proxyRes.headers['x-lensmcp-pod'] = pod;
451
+ // The gateway owns the edge response: drop the upstream framework leak
452
+ // (e.g. NestJS/Express `x-powered-by`) and brand the hop as the LensMCP gateway.
453
+ delete proxyRes.headers['x-powered-by'];
454
+ proxyRes.headers['server'] = 'LensMCP';
455
+ });
456
+ // Aggregated host→service traffic edges, flushed to the lens periodically —
457
+ // the request-derived topology (nodes/edges appear from observed traffic).
458
+ const edges = new Map();
459
+ const recordEdge = (route, ms, status, caller) => {
460
+ const host = route.host ?? '(default)';
461
+ const key = (caller ? caller + '⇒' : '') + host + '→' + route.project;
462
+ const e = edges.get(key) ?? { host, project: route.project, caller, count: 0, totalMs: 0, errors: 0 };
463
+ e.count += 1;
464
+ e.totalMs += ms;
465
+ if (status >= 500 || status === 0)
466
+ e.errors += 1;
467
+ edges.set(key, e);
468
+ };
469
+ const edgeFlusher = setInterval(() => {
470
+ for (const e of edges.values()) {
471
+ const avgMs = Math.round(e.totalMs / e.count);
472
+ emit(e.errors ? 'warning' : 'info', `traffic ${e.host} → ${e.project}: ${e.count} req, ~${avgMs}ms${e.errors ? `, ${e.errors} errors` : ''}`, `gateway-traffic:${e.host}->${e.project}`, { kind: 'gateway-traffic', host: e.host, project: e.project, caller: e.caller, count: e.count, avgMs, errors: e.errors });
473
+ }
474
+ edges.clear();
475
+ }, options.trafficFlushMs ?? 5000);
476
+ edgeFlusher.unref();
477
+ const forward = async (route, req, res) => {
478
+ const startedAt = Date.now();
479
+ const trace = req.__lensmcpTrace;
480
+ res.on('close', () => {
481
+ recordEdge(route, Date.now() - startedAt, res.statusCode ?? 0, req.__lensmcpCaller);
482
+ finishTrace(trace, route.project, res.statusCode ?? 0, {
483
+ ...(req.__lensmcpPod ? { pod: req.__lensmcpPod } : {}),
484
+ ...(req.__lensmcpCaller ? { caller: req.__lensmcpCaller } : {}),
485
+ });
486
+ });
487
+ if (route.prependPrefix && !req.url?.startsWith(route.prependPrefix)) {
488
+ req.url = route.prependPrefix + (req.url ?? '/');
489
+ }
490
+ if (route.pool) {
491
+ const svc = route.svc;
492
+ if (svc) {
493
+ svc.lastUsed = Date.now();
494
+ svc.inflight += 1;
495
+ res.on('close', () => { svc.inflight -= 1; svc.lastUsed = Date.now(); });
496
+ }
497
+ let sock = pickSock(route.pool, scanTtlMs);
498
+ if (!sock && svc) {
499
+ // scale from zero: start the service, buffer this request until pods are up
500
+ traceStep(trace, 'cold-start', { project: route.project });
501
+ if (await ensureUp(svc))
502
+ sock = pickSock(route.pool, scanTtlMs);
503
+ }
504
+ if (svc)
505
+ scaleUp(svc);
506
+ if (!sock) {
507
+ res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
508
+ const errs = svc?.lastErrors?.length ? '\n\nService errors (from build/runtime):\n ' + svc.lastErrors.join('\n ') : '';
509
+ res.end(`Service ${route.project} is not reachable (no pods in ${route.pool.dir}).${errs}\nFix the error — the watcher recovers and the next request retries automatically.`);
510
+ return;
511
+ }
512
+ req.__lensmcpPod = path.basename(sock, '.sock');
513
+ traceStep(trace, 'pod-select', { pod: path.basename(sock, '.sock'), pods: route.pool.socks.length, strategy: 'round-robin' });
514
+ proxy.web(req, res, { target: { socketPath: sock }, autoRewrite: true, agent: httpAgent }, (err) => {
515
+ route.pool.socks = route.pool.socks.filter((s) => s !== sock); // drop dead pod until rescan
516
+ route.pool.idx = -1; // removing an element shifts indices — reset so the next pick walks cleanly from 0
517
+ try {
518
+ fs.unlinkSync(sock);
519
+ }
520
+ catch { /* already gone */ } // a refused sock is a GHOST file — remove it so rescans stay truthful
521
+ console.error(`[gateway] pod ${path.basename(sock)} of ${route.project}:`, err.message);
522
+ res.statusCode = 502;
523
+ res.end(`Pod ${path.basename(sock)} of ${route.project} unavailable — retry.`);
524
+ });
525
+ return;
526
+ }
527
+ proxy.web(req, res, { target: route.target, autoRewrite: true, changeOrigin: true, agent: agentForTarget(route.target) }, (err) => {
528
+ console.error(`[gateway] upstream ${route.project} (${route.target}):`, err.message);
529
+ res.statusCode = 502;
530
+ res.end(`Upstream ${route.project} unavailable.`);
531
+ });
532
+ };
533
+ // The gateway OWNS these headers — strip any client-supplied copy at ingress so identity
534
+ // + trust can only originate from the gateway (mirrors the prod gateway's stripTrust).
535
+ const GATEWAY_OWNED_HEADERS = ['x-internal-token', 'x-api-key-id', 'x-user-id', 'x-tenant-id', 'x-user-roles', 'x-user-permissions'];
536
+ const cors = (req, res) => {
537
+ const origin = String(req.headers.origin ?? '*');
538
+ res.setHeader('access-control-allow-origin', origin);
539
+ res.setHeader('access-control-allow-credentials', 'true');
540
+ res.setHeader('vary', 'Origin');
541
+ };
542
+ const handler = (req, res) => {
543
+ // STRIP client-supplied trust headers before anything else: the gateway is
544
+ // the ONLY party allowed to assert `x-internal-token` (came through me) and
545
+ // `x-api-key-id` (validated caller identity). A client that sends either
546
+ // must not have it forwarded — the gateway re-stamps them below where
547
+ // earned. Without this, a keyless/TCP route would leak a spoofed token and
548
+ // any public route would forward a spoofed caller id to downstream audit.
549
+ for (const h of GATEWAY_OWNED_HEADERS)
550
+ delete req.headers[h];
551
+ const trace = beginTrace(req);
552
+ if (trace) {
553
+ traceStep(trace, 'received', { host: trace.host, method: trace.method, url: trace.url });
554
+ req.__lensmcpTrace = trace;
555
+ }
556
+ const route = match(req);
557
+ if (!route) {
558
+ // gateway-owned endpoints are cross-origin by design (the app calls
559
+ // api.<domain>/health from app.<domain>) — answer CORS here; routed
560
+ // paths inherit CORS from the services themselves.
561
+ cors(req, res);
562
+ if (req.method === 'OPTIONS') {
563
+ res.writeHead(204, {
564
+ 'access-control-allow-methods': 'GET,HEAD,OPTIONS',
565
+ 'access-control-allow-headers': String(req.headers['access-control-request-headers'] ?? '*'),
566
+ });
567
+ res.end();
568
+ return;
569
+ }
570
+ // Host-root infra: when a host only has PATH-mounted services (pure
571
+ // microservices under api.<domain>/{prefix}), the gateway itself owns
572
+ // the root — health aggregation + service discovery, like a real edge.
573
+ const host = (req.headers.host || '').split(':')[0];
574
+ const mounted = routes.filter((r) => r.host && (0, manifest_1.hostMatches)(r.host, host) && r.prefix);
575
+ const url = (req.url ?? '/').split('?')[0];
576
+ if (mounted.length > 0 && (url === '/health' || url === '/healthz')) {
577
+ const services = mounted.map((r) => ({
578
+ project: r.project,
579
+ prefix: r.prefix,
580
+ pods: r.pool ? (pickSock(r.pool, 0), r.pool.socks.length) : undefined,
581
+ state: r.svc?.state ?? (r.target ? 'up' : 'unknown'),
582
+ }));
583
+ const ok = services.every((s) => s.state !== 'down' || true);
584
+ res.writeHead(ok ? 200 : 503, { 'content-type': 'application/json' });
585
+ res.end(JSON.stringify({ status: 'ok', host, services }, null, 2));
586
+ return;
587
+ }
588
+ if (mounted.length > 0 && url === '/.well-known/services') {
589
+ res.writeHead(200, { 'content-type': 'application/json' });
590
+ res.end(JSON.stringify({ host, services: mounted.map((r) => ({ prefix: r.prefix, project: r.project, internal: !!r.internal })) }, null, 2));
591
+ return;
592
+ }
593
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
594
+ res.end(mounted.length > 0
595
+ ? `No service mounted at ${url}. Mounted on ${host}: ${[...new Set(mounted.map((r) => r.prefix))].join(', ')} (+ /health, /.well-known/services)`
596
+ : 'No gateway route for this hostname.');
597
+ return;
598
+ }
599
+ traceStep(trace, 'route-match', {
600
+ project: route.project,
601
+ ...(route.prefix ? { prefix: route.prefix } : {}),
602
+ ...(route.host ? { routeHost: route.host } : {}),
603
+ ...(route.internal ? { internal: true } : {}),
604
+ });
605
+ if (route.internal) {
606
+ // service-to-service: validate the CALLER's api key at the boundary
607
+ const presented = String(req.headers['x-api-key'] ?? '');
608
+ const caller = keyToProject.get(presented);
609
+ if (!caller) {
610
+ traceStep(trace, 'auth', { mode: 'x-api-key', ok: false });
611
+ finishTrace(trace, route.project, 401);
612
+ res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' });
613
+ res.end('Invalid or missing x-api-key for internal route.');
614
+ return;
615
+ }
616
+ req.headers['x-api-key-id'] = caller; // audit + lens attribution
617
+ req.__lensmcpCaller = caller;
618
+ delete req.headers['x-api-key'];
619
+ traceStep(trace, 'auth', { mode: 'x-api-key', ok: true, caller });
620
+ }
621
+ else {
622
+ // Edge auth (verify the end-user JWT + inject trusted identity + enforce per-route rules),
623
+ // mirroring the prod gateway. Only enforced when the service declares `cluster.auth`
624
+ // (opt-in migration — un-migrated services stay open, as before).
625
+ const policy = route.auth;
626
+ if (policy) {
627
+ const url0 = (req.url ?? '/').split('?')[0];
628
+ const method = (req.method ?? 'GET').toUpperCase();
629
+ const resolved = (0, manifest_1.authRuleFor)(policy, url0, method);
630
+ if (resolved.mode === 'jwt') {
631
+ const claims = verifyDevJwt(req);
632
+ if (!claims) {
633
+ traceStep(trace, 'auth', { mode: 'jwt', ok: false });
634
+ finishTrace(trace, route.project, 401);
635
+ res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' });
636
+ res.end('Unauthorized: invalid or missing token');
637
+ return;
638
+ }
639
+ if (resolved.rule && !(0, manifest_1.evalRule)(resolved.rule, devAttrs(req, claims))) {
640
+ traceStep(trace, 'authz', { ok: false });
641
+ finishTrace(trace, route.project, 403);
642
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
643
+ res.end('Forbidden: insufficient permission');
644
+ return;
645
+ }
646
+ stampIdentity(req, claims);
647
+ traceStep(trace, 'auth', { mode: 'jwt', ok: true });
648
+ }
649
+ else {
650
+ traceStep(trace, 'auth', { mode: resolved.mode });
651
+ }
652
+ }
653
+ else {
654
+ traceStep(trace, 'auth', { mode: 'public', unconfigured: true });
655
+ }
656
+ // Custom user middleware (if any) runs AFTER core auth.
657
+ if (middleware) {
658
+ try {
659
+ middleware(req);
660
+ }
661
+ catch (e) {
662
+ traceStep(trace, 'auth', { mode: 'middleware', ok: false, reason: e.message });
663
+ finishTrace(trace, route.project, 401);
664
+ res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' });
665
+ res.end(`Gateway middleware rejected the request: ${e.message}`);
666
+ return;
667
+ }
668
+ }
669
+ }
670
+ if (route.pool && serviceKeys[route.project]) {
671
+ req.headers['x-internal-token'] = serviceKeys[route.project]; // pods trust the gateway
672
+ }
673
+ void forward(route, req, res);
674
+ };
675
+ const upgrade = (req, socket, head) => {
676
+ // WS upgrades enforce the SAME trust model as HTTP — otherwise an
677
+ // internal.<host> socket would be reachable with no x-api-key, and a
678
+ // public socket would reach pods without the x-internal-token they expect.
679
+ for (const h of GATEWAY_OWNED_HEADERS)
680
+ delete req.headers[h];
681
+ const route = match(req);
682
+ if (!route) {
683
+ socket.destroy();
684
+ return;
685
+ }
686
+ if (route.internal) {
687
+ const caller = keyToProject.get(String(req.headers['x-api-key'] ?? ''));
688
+ if (!caller) {
689
+ socket.destroy();
690
+ return;
691
+ } // no valid key → no internal WS
692
+ req.headers['x-api-key-id'] = caller;
693
+ delete req.headers['x-api-key'];
694
+ }
695
+ else {
696
+ const policy = route.auth;
697
+ if (policy) {
698
+ const resolved = (0, manifest_1.authRuleFor)(policy, (req.url ?? '/').split('?')[0], (req.method ?? 'GET').toUpperCase());
699
+ if (resolved.mode === 'jwt') {
700
+ const claims = verifyDevJwt(req);
701
+ if (!claims) {
702
+ socket.destroy();
703
+ return;
704
+ }
705
+ if (resolved.rule && !(0, manifest_1.evalRule)(resolved.rule, devAttrs(req, claims))) {
706
+ socket.destroy();
707
+ return;
708
+ }
709
+ stampIdentity(req, claims);
710
+ }
711
+ }
712
+ if (middleware) {
713
+ try {
714
+ middleware(req);
715
+ }
716
+ catch {
717
+ socket.destroy();
718
+ return;
719
+ }
720
+ }
721
+ }
722
+ if (route.pool && serviceKeys[route.project]) {
723
+ req.headers['x-internal-token'] = serviceKeys[route.project];
724
+ }
725
+ if (route.prependPrefix && !req.url?.startsWith(route.prependPrefix)) {
726
+ req.url = route.prependPrefix + (req.url ?? '/');
727
+ }
728
+ const target = route.pool
729
+ ? (() => { const s = pickSock(route.pool, scanTtlMs); return s ? { socketPath: s } : undefined; })()
730
+ : route.target;
731
+ if (!target) {
732
+ socket.destroy();
733
+ return;
734
+ }
735
+ proxy.ws(req, socket, head, { target, changeOrigin: !route.pool, agent: agentForTarget(target) }, () => socket.destroy());
736
+ };
737
+ // --- TLS + listeners ------------------------------------------------------------
738
+ let pem;
739
+ let caPath;
740
+ if (https) {
741
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
742
+ const basicSsl = require('../../basic-ssl');
743
+ const cacheDir = path.join(context.root, 'node_modules', '.cache', 'davnx-webpack');
744
+ const hosts = routes.map((r) => r.host).filter((h) => !!h);
745
+ pem = basicSsl.getCertificateSync(cacheDir, 'gateway', hosts);
746
+ caPath = basicSsl.caCertPath(cacheDir);
747
+ }
748
+ const servers = [];
749
+ const boundPorts = [];
750
+ for (const port of ports) {
751
+ const server = pem
752
+ ? // eslint-disable-next-line @typescript-eslint/no-require-imports
753
+ require('node:https').createServer({ key: pem, cert: pem }, handler)
754
+ : http.createServer(handler);
755
+ server.on('upgrade', upgrade);
756
+ server.on('error', (err) => console.error(`[gateway] port ${port}: ${err.code}`));
757
+ await new Promise((resolve) => {
758
+ server.listen(port, () => {
759
+ const actual = server.address()?.port ?? port;
760
+ boundPorts.push(actual);
761
+ console.log(`[gateway] Listening on ${pem ? 'https' : 'http'}://localhost:${actual}`);
762
+ resolve();
763
+ });
764
+ });
765
+ servers.push(server);
766
+ }
767
+ console.log('[gateway] Routes:');
768
+ for (const r of routes) {
769
+ const dest = r.pool ? `pods@${r.pool.dir}` : r.target;
770
+ console.log(`[gateway] ${r.host ?? '(default)'} → ${dest}${r.prependPrefix ? ` (+${r.prependPrefix})` : ''}${r.internal ? ' [internal: no middleware]' : ''} [${r.project}]`);
771
+ }
772
+ if (caPath)
773
+ console.log(`[gateway] CA: ${caPath} — one-time setup via the trust executor.`);
774
+ emit('info', `gateway up: ${routes.length} routes on ${boundPorts.join('/')}`, 'cluster-gateway', {
775
+ kind: 'gateway-up',
776
+ ports: boundPorts,
777
+ routes: routes.map((r) => ({ host: r.host ?? '(default)', project: r.project, mode: r.pool ? 'pods' : 'tcp', internal: !!r.internal, prependPrefix: r.prependPrefix })),
778
+ });
779
+ const stop = async () => {
780
+ if (stopped)
781
+ return;
782
+ stopped = true;
783
+ clearInterval(edgeFlusher);
784
+ clearInterval(sweeper);
785
+ emit('info', 'gateway down', 'cluster-gateway', { kind: 'gateway-down' });
786
+ for (const svc of services)
787
+ killSpawned(svc, 'gateway shutdown');
788
+ await Promise.all(servers.map((s) => new Promise((r) => s.close(() => r()))));
789
+ try {
790
+ proxy.close?.();
791
+ }
792
+ catch { /* ignore */ }
793
+ httpAgent.destroy();
794
+ httpsAgent.destroy(); // release pooled upstream sockets
795
+ };
796
+ return { ports: boundPorts, routes, services, stop };
797
+ }