@lensmcp/cluster 1.12.0 → 1.13.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 (98) hide show
  1. package/executors/gateway/gateway.lib.d.ts +15 -167
  2. package/executors/gateway/gateway.lib.d.ts.map +1 -1
  3. package/executors/gateway/gateway.lib.js +24 -1041
  4. package/executors/gateway/main.prod-gateway.js +74 -2
  5. package/executors/gateway/prod-gateway.lib.d.ts +19 -151
  6. package/executors/gateway/prod-gateway.lib.d.ts.map +1 -1
  7. package/executors/gateway/prod-gateway.lib.js +16 -863
  8. package/executors/gateway/prod-runtime/access-log.d.ts +9 -0
  9. package/executors/gateway/prod-runtime/access-log.d.ts.map +1 -0
  10. package/executors/gateway/prod-runtime/access-log.js +24 -0
  11. package/executors/gateway/prod-runtime/app.d.ts +23 -0
  12. package/executors/gateway/prod-runtime/app.d.ts.map +1 -0
  13. package/executors/gateway/prod-runtime/app.js +123 -0
  14. package/executors/gateway/prod-runtime/auth.d.ts +30 -0
  15. package/executors/gateway/prod-runtime/auth.d.ts.map +1 -0
  16. package/executors/gateway/prod-runtime/auth.js +51 -0
  17. package/executors/gateway/prod-runtime/cors.d.ts +19 -0
  18. package/executors/gateway/prod-runtime/cors.d.ts.map +1 -0
  19. package/executors/gateway/prod-runtime/cors.js +40 -0
  20. package/executors/gateway/prod-runtime/edge.d.ts +16 -0
  21. package/executors/gateway/prod-runtime/edge.d.ts.map +1 -0
  22. package/executors/gateway/prod-runtime/edge.js +65 -0
  23. package/executors/gateway/prod-runtime/handler.d.ts +32 -0
  24. package/executors/gateway/prod-runtime/handler.d.ts.map +1 -0
  25. package/executors/gateway/prod-runtime/handler.js +226 -0
  26. package/executors/gateway/prod-runtime/hooks.d.ts +20 -0
  27. package/executors/gateway/prod-runtime/hooks.d.ts.map +1 -0
  28. package/executors/gateway/prod-runtime/hooks.js +42 -0
  29. package/executors/gateway/prod-runtime/observability.d.ts +23 -0
  30. package/executors/gateway/prod-runtime/observability.d.ts.map +1 -0
  31. package/executors/gateway/prod-runtime/observability.js +125 -0
  32. package/executors/gateway/prod-runtime/rollout.d.ts +18 -0
  33. package/executors/gateway/prod-runtime/rollout.d.ts.map +1 -0
  34. package/executors/gateway/prod-runtime/rollout.js +103 -0
  35. package/executors/gateway/prod-runtime/routing.d.ts +4 -0
  36. package/executors/gateway/prod-runtime/routing.d.ts.map +1 -0
  37. package/executors/gateway/prod-runtime/routing.js +40 -0
  38. package/executors/gateway/prod-runtime/server.d.ts +3 -0
  39. package/executors/gateway/prod-runtime/server.d.ts.map +1 -0
  40. package/executors/gateway/prod-runtime/server.js +79 -0
  41. package/executors/gateway/prod-runtime/trust.d.ts +12 -0
  42. package/executors/gateway/prod-runtime/trust.d.ts.map +1 -0
  43. package/executors/gateway/prod-runtime/trust.js +32 -0
  44. package/executors/gateway/prod-runtime/types.d.ts +241 -0
  45. package/executors/gateway/prod-runtime/types.d.ts.map +1 -0
  46. package/executors/gateway/prod-runtime/types.js +2 -0
  47. package/executors/gateway/prod-runtime/upgrade.d.ts +21 -0
  48. package/executors/gateway/prod-runtime/upgrade.d.ts.map +1 -0
  49. package/executors/gateway/prod-runtime/upgrade.js +116 -0
  50. package/executors/gateway/prod-runtime/upstream.d.ts +13 -0
  51. package/executors/gateway/prod-runtime/upstream.d.ts.map +1 -0
  52. package/executors/gateway/prod-runtime/upstream.js +21 -0
  53. package/executors/gateway/runtime/auth.d.ts +8 -0
  54. package/executors/gateway/runtime/auth.d.ts.map +1 -0
  55. package/executors/gateway/runtime/auth.js +60 -0
  56. package/executors/gateway/runtime/dev-auth.d.ts +9 -0
  57. package/executors/gateway/runtime/dev-auth.d.ts.map +1 -0
  58. package/executors/gateway/runtime/dev-auth.js +79 -0
  59. package/executors/gateway/runtime/discovery.d.ts +17 -0
  60. package/executors/gateway/runtime/discovery.d.ts.map +1 -0
  61. package/executors/gateway/runtime/discovery.js +111 -0
  62. package/executors/gateway/runtime/edge.d.ts +31 -0
  63. package/executors/gateway/runtime/edge.d.ts.map +1 -0
  64. package/executors/gateway/runtime/edge.js +47 -0
  65. package/executors/gateway/runtime/handler.d.ts +24 -0
  66. package/executors/gateway/runtime/handler.d.ts.map +1 -0
  67. package/executors/gateway/runtime/handler.js +165 -0
  68. package/executors/gateway/runtime/hooks.d.ts +23 -0
  69. package/executors/gateway/runtime/hooks.d.ts.map +1 -0
  70. package/executors/gateway/runtime/hooks.js +55 -0
  71. package/executors/gateway/runtime/lens-children.d.ts +13 -0
  72. package/executors/gateway/runtime/lens-children.d.ts.map +1 -0
  73. package/executors/gateway/runtime/lens-children.js +136 -0
  74. package/executors/gateway/runtime/lifecycle.d.ts +13 -0
  75. package/executors/gateway/runtime/lifecycle.d.ts.map +1 -0
  76. package/executors/gateway/runtime/lifecycle.js +179 -0
  77. package/executors/gateway/runtime/observability.d.ts +17 -0
  78. package/executors/gateway/runtime/observability.d.ts.map +1 -0
  79. package/executors/gateway/runtime/observability.js +107 -0
  80. package/executors/gateway/runtime/proxy.d.ts +20 -0
  81. package/executors/gateway/runtime/proxy.d.ts.map +1 -0
  82. package/executors/gateway/runtime/proxy.js +113 -0
  83. package/executors/gateway/runtime/scope.d.ts +16 -0
  84. package/executors/gateway/runtime/scope.d.ts.map +1 -0
  85. package/executors/gateway/runtime/scope.js +68 -0
  86. package/executors/gateway/runtime/server.d.ts +3 -0
  87. package/executors/gateway/runtime/server.d.ts.map +1 -0
  88. package/executors/gateway/runtime/server.js +168 -0
  89. package/executors/gateway/runtime/service-keys.d.ts +11 -0
  90. package/executors/gateway/runtime/service-keys.d.ts.map +1 -0
  91. package/executors/gateway/runtime/service-keys.js +49 -0
  92. package/executors/gateway/runtime/types.d.ts +234 -0
  93. package/executors/gateway/runtime/types.d.ts.map +1 -0
  94. package/executors/gateway/runtime/types.js +2 -0
  95. package/executors/gateway/runtime/upgrade.d.ts +22 -0
  96. package/executors/gateway/runtime/upgrade.d.ts.map +1 -0
  97. package/executors/gateway/runtime/upgrade.js +63 -0
  98. package/package.json +3 -3
@@ -1,1045 +1,28 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.hostMatches = void 0;
4
- exports.verifyDevJwt = verifyDevJwt;
5
- exports.stampIdentity = stampIdentity;
6
- exports.baseDomainOf = baseDomainOf;
7
- exports.readLensScope = readLensScope;
8
- exports.pickSock = pickSock;
9
- exports.discoverRoutes = discoverRoutes;
10
- exports.startGateway = startGateway;
11
- const tslib_1 = require("tslib");
3
+ exports.discoverRoutes = exports.pickSock = exports.readLensScope = exports.baseDomainOf = exports.stampIdentity = exports.verifyDevJwt = exports.startGateway = exports.hostMatches = void 0;
12
4
  /**
13
- * The gateway runtime, separated from the Nx executor so it is directly
14
- * testable: `startGateway()` returns a handle with the actually-bound ports
15
- * and a `stop()` no signals needed. The executor is a thin wrapper.
5
+ * Public facade for the dev gateway runtime.
6
+ *
7
+ * The implementation now lives in `./runtime/*` as composable LAYERS see
8
+ * `runtime/server.ts` for the composition root (`startGateway`), `runtime/types.ts`
9
+ * for the shared types + the customer **hooks** API, and the sibling modules for
10
+ * each layer (discovery, dev-auth, observability, lifecycle, lens-children, proxy,
11
+ * auth, hooks, handler, upgrade). This barrel preserves the original module's
12
+ * public surface so the executor wrapper (`gateway.impl.ts`) and the existing
13
+ * specs keep importing from one stable path, and additionally exposes the new
14
+ * hooks types so customers can author typed layers on top of the gateway.
16
15
  */
17
- const node_child_process_1 = require("node:child_process");
18
- const node_crypto_1 = require("node:crypto");
19
- const fs = tslib_1.__importStar(require("node:fs"));
20
- const http = tslib_1.__importStar(require("node:http"));
21
- const os = tslib_1.__importStar(require("node:os"));
22
- const path = tslib_1.__importStar(require("node:path"));
23
- const manifest_1 = require("./manifest");
24
- const gateway_errors_1 = require("./gateway-errors");
25
- const lens_frontend_1 = require("@lensmcp/nx-plugin/lens-frontend");
26
- var manifest_2 = require("./manifest"); // back-compat re-export
27
- Object.defineProperty(exports, "hostMatches", { enumerable: true, get: function () { return manifest_2.hostMatches; } });
28
- /**
29
- * Send a gateway-generated edge error as the JSON envelope `{ key, status, traceId, message? }` (NOT
30
- * `text/plain`) + an `x-request-id` header — the SAME localized contract a service returns, so a browser
31
- * app renders `t(key)` and surfaces the trace id. Mirrors the prod gateway's `sendError`.
32
- */
33
- const sendError = (res, req, status, reason, detail) => {
34
- const traceId = (0, gateway_errors_1.ensureRequestId)(req);
35
- if (!res.headersSent)
36
- res.writeHead(status, { 'content-type': gateway_errors_1.GATEWAY_ERROR_CONTENT_TYPE, 'x-request-id': traceId });
37
- res.end((0, gateway_errors_1.gatewayErrorBody)(status, reason, traceId, detail));
38
- };
39
- // eslint-disable-next-line @typescript-eslint/no-require-imports
40
- const httpProxy = require('http-proxy');
41
- /**
42
- * Dev edge auth (HS256), mirroring the prod gateway: verify the end-user token, then stamp the
43
- * trusted identity headers the services read (via GatewayTrustGuard). RS256/JWKS is prod's job;
44
- * dev shares the `AUTH_JWT_SECRET` the auth host signs with. Returns the claims, or undefined.
45
- */
46
- function verifyDevJwt(req) {
47
- const secret = process.env['AUTH_JWT_SECRET'] ?? 'tetros-dev-secret';
48
- const auth = String((Array.isArray(req.headers['authorization']) ? req.headers['authorization'][0] : req.headers['authorization']) ?? '');
49
- if (!auth.startsWith('Bearer '))
50
- return undefined;
51
- const [h, p, s] = auth.slice(7).split('.');
52
- if (!h || !p || !s)
53
- return undefined;
54
- // An IdP that signs ASYMMETRIC (RS256 + JWKS — e.g. tetros's auth host) does NOT use the HS256 dev
55
- // shared-secret this dev edge assumed. The PROD gateway verifies RS256 via jwks-verify; in dev we
56
- // DECODE + exp-check the claims (no signature check) — the gateway is the only local ingress, the token
57
- // came from the trusted local IdP, and a dev IdP's ephemeral per-pod keys make in-cluster JWKS
58
- // verification unreliable. (The HS256 path below stays for services using the shared-secret convention.)
59
- let alg;
60
- try {
61
- alg = JSON.parse(Buffer.from(h, 'base64url').toString())['alg'];
62
- }
63
- catch {
64
- return undefined;
65
- }
66
- if (typeof alg === 'string' && alg !== 'HS256') {
67
- try {
68
- const rsClaims = JSON.parse(Buffer.from(p, 'base64url').toString());
69
- if (typeof rsClaims['exp'] === 'number' && Date.now() / 1000 > rsClaims['exp'])
70
- return undefined;
71
- return rsClaims;
72
- }
73
- catch {
74
- return undefined;
75
- }
76
- }
77
- const expected = (0, node_crypto_1.createHmac)('sha256', secret).update(`${h}.${p}`).digest('base64url');
78
- const a = Buffer.from(s);
79
- const b = Buffer.from(expected);
80
- if (a.length !== b.length || !(0, node_crypto_1.timingSafeEqual)(a, b))
81
- return undefined;
82
- try {
83
- const claims = JSON.parse(Buffer.from(p, 'base64url').toString());
84
- if (typeof claims['exp'] === 'number' && Date.now() / 1000 > claims['exp'])
85
- return undefined;
86
- return claims;
87
- }
88
- catch {
89
- return undefined;
90
- }
91
- }
92
- /** Stamp verified identity onto the forwarded request — tenant from the `tid` CLAIM, never a client header. */
93
- function stampIdentity(req, claims) {
94
- const set = (k, v) => { if (v !== undefined && v !== null && v !== '')
95
- req.headers[k] = String(v); };
96
- set('x-user-id', claims['sub']);
97
- set('x-tenant-id', claims['tid']);
98
- const roles = claims['roles'];
99
- if (Array.isArray(roles) && roles.length)
100
- req.headers['x-user-roles'] = roles.map(String).join(',');
101
- const perms = claims['perms'];
102
- if (Array.isArray(perms) && perms.length)
103
- req.headers['x-user-permissions'] = perms.map(String).join(',');
104
- }
105
- const devAttrs = (req, claims) => ({
106
- ...claims,
107
- ip: req.socket.remoteAddress ?? '',
108
- method: (req.method ?? 'GET').toUpperCase(),
109
- path: (req.url ?? '/').split('?')[0],
110
- });
111
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
112
- /** Extract the port from an `upstream` URL like `http://localhost:5173`. */
113
- function lensPortFromUpstream(upstream) {
114
- if (!upstream)
115
- return undefined;
116
- const m = /:(\d+)/.exec(upstream);
117
- return m ? Number(m[1]) : undefined;
118
- }
119
- /** The base domain for the lens dashboard host — the longest common dotted
120
- * suffix of the declared cluster hosts (e.g. ['api.tetros.ai.local',
121
- * 'tetros.ai.local'] → 'tetros.ai.local'). Falls back to the first host, then
122
- * to 'lens.local' if no host is declared. */
123
- function baseDomainOf(hosts) {
124
- const clean = hosts.filter((h) => !!h && !h.startsWith('*.'));
125
- if (clean.length === 0)
126
- return 'local';
127
- // Use the SHORTEST host as the candidate base (most likely the apex), then
128
- // require every other host to end with it.
129
- const shortest = clean.reduce((a, b) => (b.length < a.length ? b : a));
130
- if (clean.every((h) => h === shortest || h.endsWith('.' + shortest)))
131
- return shortest;
132
- // No clean common apex — derive the trailing 2-3 labels of the shortest host.
133
- const parts = shortest.split('.');
134
- return parts.slice(-Math.min(parts.length, 3)).join('.');
135
- }
136
- /** Per-workspace lens scope (key + dashboard port + mount path). Prefers the
137
- * CLI-written `.lensmcp/config.json` (so the gateway agrees with `lensmcp gateway`
138
- * / `lensmcp dashboard`); falls back to deriving from the workspace. Mirrors the
139
- * derivation in the `lensmcp` CLI's `workspace-scope` (kept in lock-step). */
140
- function readLensScope(root) {
141
- try {
142
- const cfg = JSON.parse(fs.readFileSync(path.join(root, '.lensmcp', 'config.json'), 'utf8'));
143
- if (cfg && typeof cfg.key === 'string') {
144
- return {
145
- key: cfg.key,
146
- dashboardPort: cfg.ports?.dashboard ?? 4321,
147
- basePath: cfg.dashboardBasePath ?? '/' + cfg.key,
148
- };
149
- }
150
- }
151
- catch {
152
- /* no config yet — derive below */
153
- }
154
- const key = lensKeyFrom(root);
155
- return { key, dashboardPort: 4321, basePath: '/' + key };
156
- }
157
- function lensKeyFrom(root) {
158
- try {
159
- const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
160
- if (pkg?.name)
161
- return lensSlug(pkg.name);
162
- }
163
- catch {
164
- /* fall through to dir name */
165
- }
166
- return lensSlug(path.basename(root));
167
- }
168
- function lensSlug(input) {
169
- return (input
170
- .toLowerCase()
171
- .replace(/^@[^/]+\//, '')
172
- .replace(/[^a-z0-9]+/g, '-')
173
- .replace(/(^-|-$)/g, '') || 'workspace');
174
- }
175
- /** Round-robin over the pool's sockets, rescanning the dir when the cache ages out. */
176
- function pickSock(pool, scanTtlMs = 1500) {
177
- const now = Date.now();
178
- if (now - pool.scannedAt > scanTtlMs) {
179
- try {
180
- pool.socks = fs.readdirSync(pool.dir)
181
- .filter((f) => /^child-\d+\.sock$/.test(f))
182
- .map((f) => path.join(pool.dir, f));
183
- }
184
- catch {
185
- pool.socks = [];
186
- }
187
- pool.scannedAt = now;
188
- }
189
- if (pool.socks.length === 0)
190
- return undefined;
191
- pool.idx = (pool.idx + 1) % pool.socks.length;
192
- return pool.socks[pool.idx];
193
- }
194
- /**
195
- * Read every project's `cluster` (or legacy `davnx`) declaration and build
196
- * the route table: host routes first (plus auto `internal.<host>` for pod
197
- * services), the `default` catch-all last.
198
- */
199
- function discoverRoutes(rootDir, projects, sockBaseDir = os.tmpdir()) {
200
- const routes = [];
201
- const services = [];
202
- let defaultRoute;
203
- for (const [name, p] of Object.entries(projects)) {
204
- let decl;
205
- try {
206
- const raw = JSON.parse(fs.readFileSync(path.join(rootDir, p.root, 'project.json'), 'utf8'));
207
- decl = raw.cluster ?? raw.davnx;
208
- }
209
- catch {
210
- continue;
211
- }
212
- if (!decl)
213
- continue;
214
- let pool;
215
- let svc;
216
- let target;
217
- let lens;
218
- if (decl.service) {
219
- // `lens` applies to FRONTEND apps only; a pod service is already
220
- // instrumented by the serve executor's node-instrumentation graft.
221
- if (decl.lens)
222
- console.warn(`[gateway] ${name}: cluster.lens is ignored for pod (service) decls — backends are instrumented by serve-hmr.`);
223
- pool = { dir: path.join(sockBaseDir, `${decl.service}-devserver`), socks: [], idx: -1, scannedAt: 0 };
224
- svc = { project: name, decl, pool, state: 'down', lastUsed: Date.now(), inflight: 0, lastScaleAt: 0 };
225
- services.push(svc);
226
- }
227
- else if (decl.lens) {
228
- // Lens-mode frontend: the gateway SPAWNS this app's Vite + instrumentation
229
- // (deterministically on `port`) and upstreams to it. One LensFrontend per
230
- // project is SHARED across its host/default routes (so the spawn fires once).
231
- const port = decl.port ?? lensPortFromUpstream(decl.upstream) ?? 5173;
232
- target = `http://localhost:${port}`;
233
- lens = { project: name, projectRoot: path.join(rootDir, p.root), port };
234
- }
235
- else {
236
- target = decl.upstream ?? (decl.port ? `http://localhost:${decl.port}` : undefined);
237
- if (!target) {
238
- console.warn(`[gateway] ${name}: no service/upstream/port — skipped.`);
239
- continue;
240
- }
241
- }
242
- const base = { project: name, prependPrefix: decl.prependPrefix, target, pool, svc, prefix: decl.path, auth: decl.auth, lens };
243
- if (decl.host) {
244
- routes.push({ ...base, host: decl.host });
245
- // internal.<host>: east-west traffic, same pods, NO middleware.
246
- if (pool && decl.internalHost !== false) {
247
- routes.push({
248
- ...base,
249
- host: typeof decl.internalHost === 'string' ? decl.internalHost : `internal.${decl.host}`,
250
- internal: true,
251
- });
252
- }
253
- }
254
- if (decl.default)
255
- defaultRoute = { ...base, prefix: undefined };
256
- }
257
- if (defaultRoute)
258
- routes.push(defaultRoute);
259
- // specificity sort (shared with prod): host+path beats host-only beats
260
- // catch-all — so a path-mounted service (api.x/channels) wins over the
261
- // host's owner (api.x → api).
262
- (0, manifest_1.sortBySpecificity)(routes);
263
- return { routes, services };
264
- }
265
- async function startGateway(options, context) {
266
- const https = options.https !== false;
267
- const ports = options.ports?.length ? options.ports : [443];
268
- const scanTtlMs = options.scanTtlMs ?? 1500;
269
- const startupTimeoutMs = options.startupTimeoutMs ?? 120_000;
270
- // Set by stop(); consulted by spawnService/ensureUp so an in-flight cold
271
- // start neither spawns a process nor logs after the gateway is torn down.
272
- let stopped = false;
273
- // --- LensMCP bus: the gateway is a first-class lens source -------------------
274
- // Appends BaseEvent-shaped lines to the shared event file the MCP tails
275
- // (same rendezvous the vite plugin and nest instrumentation use), so the
276
- // lens sees the cluster itself: services up/down, cold starts, autoscale,
277
- // idle kills, and aggregated host→service traffic edges for the topology.
278
- const eventFile = process.env['LENSMCP_EVENT_FILE'] ?? path.join(context.root, '.lensmcp', 'events.jsonl');
279
- const eid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 12);
280
- const emit = (severity, title, fingerprint, raw, flowCtx) => {
281
- try {
282
- fs.mkdirSync(path.dirname(eventFile), { recursive: true });
283
- fs.appendFileSync(eventFile, JSON.stringify({
284
- id: eid(), sessionId: 'pending', timestamp: Date.now(),
285
- source: 'gateway', category: 'cluster', severity,
286
- context: { sessionId: 'pending', ...flowCtx }, fingerprint, title, raw,
287
- }) + '\n');
288
- }
289
- catch { /* lens offline — never block traffic */ }
290
- };
291
- const hdr = (v) => typeof v === 'string' && v !== '' ? v : Array.isArray(v) ? v[0] : undefined;
292
- const beginTrace = (req) => {
293
- const flowId = hdr(req.headers['x-lensmcp-flow-id']);
294
- let requestId = hdr(req.headers['x-request-id']);
295
- if (!flowId && !requestId && !hdr(req.headers['traceparent']))
296
- return undefined;
297
- if (!requestId) {
298
- // Mint the request id AT THE EDGE and inject it downstream, so the
299
- // gateway trace and the pod's server-request/spans share one id —
300
- // that's the join key that attributes pod events to the routed service.
301
- requestId = eid();
302
- req.headers['x-request-id'] = requestId;
303
- }
304
- return {
305
- startedAt: Date.now(),
306
- method: (req.method ?? 'GET').toUpperCase(),
307
- host: (req.headers.host || '').split(':')[0],
308
- url: req.url ?? '/',
309
- steps: [],
310
- ...(flowId ? { flowId } : {}),
311
- ...(requestId ? { requestId } : {}),
312
- ...(hdr(req.headers['x-lensmcp-origin-node-id']) ? { originNodeId: hdr(req.headers['x-lensmcp-origin-node-id']) } : {}),
313
- };
314
- };
315
- const traceStep = (t, stepName, info) => {
316
- if (!t)
317
- return;
318
- t.steps.push({ step: stepName, atMs: Date.now() - t.startedAt, ...info });
319
- };
320
- const finishTrace = (t, project, status, extra) => {
321
- if (!t || t.done)
322
- return;
323
- t.done = true;
324
- const durationMs = Date.now() - t.startedAt;
325
- emit(status >= 500 ? 'error' : 'info', `gateway ${t.method} ${t.host}${t.url} → ${status} (${durationMs}ms)`, `gateway-request:${project}`, {
326
- kind: 'gateway-request', project, host: t.host, method: t.method, url: t.url,
327
- status, durationMs, startedAt: t.startedAt, steps: t.steps, ...extra,
328
- }, {
329
- ...(t.flowId ? { flowId: t.flowId } : {}),
330
- ...(t.requestId ? { requestId: t.requestId } : {}),
331
- ...(t.originNodeId ? { originNodeId: t.originNodeId } : {}),
332
- });
333
- };
334
- // --- discover ---------------------------------------------------------------
335
- const { routes, services } = discoverRoutes(context.root, context.projectsConfigurations?.projects ?? {});
336
- if (routes.length === 0) {
337
- throw new Error('[gateway] no project declares a `cluster` field — nothing to route.');
338
- }
339
- // --- lens-mode children: the dashboard + MCP SINGLETONS, and `lens:true` apps ---
340
- // The gateway owns ONE dashboard and ONE MCP server cluster-wide (each
341
- // `lens:true` app runs only its Vite + instrumentation, publishing to the
342
- // SHARED event bus — never its own dashboard/MCP, which would collide on
343
- // :4321 / :3000). These children share the pods' lifecycle: best-effort
344
- // (a crash doesn't tear the gateway down) and reaped on stop().
345
- const lensChildren = [];
346
- const lensRespawn = new Map();
347
- // A `SpawnChild` over the gateway's managed-children array — the SAME shape
348
- // `agent-dev` passes to `spawnLensFrontend`, so the helper behaves identically.
349
- const spawnManagedChild = (label, bin, args, env, optional = false, respawn) => {
350
- const child = (0, node_child_process_1.spawn)(bin, args, { cwd: context.root, env: { ...process.env, ...(env ?? {}) }, stdio: 'inherit' });
351
- lensChildren.push(child);
352
- child.on('exit', (code, sig) => {
353
- if (stopped)
354
- return;
355
- if (optional && respawn) {
356
- const used = lensRespawn.get(label) ?? 0;
357
- if (used < respawn.max) {
358
- lensRespawn.set(label, used + 1);
359
- console.warn(`[gateway] lens child ${label} exited (code=${code} sig=${sig}); respawning in ${Math.round(respawn.delayMs / 1000)}s (${used + 1}/${respawn.max}).`);
360
- const t = setTimeout(() => { if (!stopped)
361
- spawnManagedChild(label, bin, args, env, optional, respawn); }, respawn.delayMs);
362
- t.unref?.();
363
- return;
364
- }
365
- }
366
- // Singletons (dashboard/MCP) and exhausted-respawn children just log —
367
- // a dead dashboard must never kill the cluster.
368
- console.warn(`[gateway] lens child ${label} exited (code=${code} sig=${sig}); continuing.`);
369
- });
370
- return child;
371
- };
372
- const lensApps = [...new Map(routes.filter((r) => r.lens).map((r) => [r.project, r.lens])).values()];
373
- const wantDashboard = options.dashboard !== false;
374
- const wantMcp = options.mcp !== false && lensApps.length > 0;
375
- if (wantDashboard) {
376
- const dashboardBundle = (0, lens_frontend_1.findDashboardBundle)(context.root);
377
- if (!dashboardBundle) {
378
- console.warn("[gateway] lens dashboard bundle not found — skipping. Install the self-contained CLI: `npm i -D lensmcp` (it ships `bundled/dashboard.js`), or `yarn nx build @lensmcp/lensmcp-mcp` in-repo.");
379
- }
380
- else {
381
- // Per-project lens scope: the dashboard is mounted at lensmcp.local/<key>/ so
382
- // MANY projects coexist (each at its own /<key> on its own port). The dashboard
383
- // OWNS its base path (LENSMCP_DASHBOARD_BASE) and history-routes under it, so the
384
- // gateway routes pass-through (matchRoute on the prefix; the proxy never strips).
385
- const scope = readLensScope(context.root);
386
- const dashboardPort = options.dashboardPort ?? scope.dashboardPort;
387
- const lensHost = options.lensHost ?? 'lensmcp.local';
388
- const lensBase = scope.basePath; // e.g. /tetros
389
- // Pushed BEFORE the cert SANs are derived (routes.map(r=>r.host)), so lensmcp.local
390
- // auto-joins the TLS SANs. Run `gateway:trust` to add it to /etc/hosts.
391
- routes.push({ host: lensHost, prefix: lensBase, project: '(lens-dashboard)', target: `http://localhost:${dashboardPort}` });
392
- (0, manifest_1.sortBySpecificity)(routes);
393
- console.log(`[gateway] starting lens dashboard → http://localhost:${dashboardPort} (routed at https://${lensHost}${lensBase}/)`);
394
- emit('info', `lens dashboard up: ${lensHost}${lensBase} → :${dashboardPort}`, 'cluster-lens-dashboard', { kind: 'lens-dashboard-up', host: lensHost, port: dashboardPort, base: lensBase });
395
- spawnManagedChild('lens-dashboard', process.execPath, [dashboardBundle], {
396
- LENSMCP_EVENT_FILE: eventFile,
397
- LENSMCP_DASHBOARD_PORT: String(dashboardPort),
398
- LENSMCP_DASHBOARD_BASE: lensBase,
399
- });
400
- }
401
- }
402
- if (wantMcp) {
403
- const mcpBundle = (0, lens_frontend_1.findMcpBundle)(context.root);
404
- if (!mcpBundle) {
405
- console.warn("[gateway] MCP server bundle not found — skipping (lens apps will still publish to the bus; an external `lensmcp mcp` can read it). Install: `npm i -D lensmcp`.");
406
- }
407
- else {
408
- console.log(`[gateway] starting lens MCP server (${mcpBundle})`);
409
- spawnManagedChild('lens-mcp', process.execPath, [mcpBundle], {
410
- LENSMCP_EVENT_FILE: eventFile,
411
- LENSMCP_TRANSPORT: process.env['LENSMCP_TRANSPORT'] ?? 'http',
412
- LENSMCP_PORT: process.env['LENSMCP_PORT'] ?? '3000',
413
- });
414
- }
415
- }
416
- // Spawn each `lens:true` frontend's Vite + browser capture as the upstream,
417
- // via the SAME shared helper `agent-dev` uses (singletons excluded — owned above).
418
- for (const app of lensApps) {
419
- if (app.started)
420
- continue;
421
- app.started = true;
422
- try {
423
- (0, lens_frontend_1.spawnLensFrontend)({
424
- projectRoot: app.projectRoot,
425
- workspaceRoot: context.root,
426
- project: app.project,
427
- eventFile,
428
- port: app.port,
429
- chrome: true,
430
- headless: true,
431
- // Identify this app's events on the shared bus (mirrors the serve
432
- // executor's LENSMCP_PROJECT stamp for pods).
433
- viteEnv: { LENSMCP_PROJECT: app.project },
434
- log: (l) => console.log(l),
435
- }, spawnManagedChild);
436
- emit('info', `lens app started: ${app.project} (vite :${app.port})`, `cluster-lens-app:${app.project}`, { kind: 'lens-app-up', project: app.project, port: app.port });
437
- }
438
- catch (e) {
439
- console.error(`[gateway] lens app ${app.project}: ${e.message}`);
440
- }
441
- }
442
- // --- service lifecycle: scale-from-zero, autoscale, idle kill ----------------
443
- const scanNow = (pool) => { pool.scannedAt = 0; return pickSock(pool, scanTtlMs) ? pool.socks.length : 0; };
444
- const spawnService = (svc) => {
445
- if (stopped)
446
- return; // never spawn a process after the gateway is told to stop
447
- const target = `${svc.project}:${svc.decl.serveTarget ?? 'serve-hmr'}`;
448
- const min = Math.max(1, svc.decl.minPods ?? 1);
449
- console.log(`[gateway] starting ${target} (pods=${min})…`);
450
- emit('info', `service starting: ${svc.project} (${min} pod${min > 1 ? 's' : ''})`, `cluster-service:${svc.project}`, { kind: 'service-starting', project: svc.project, pods: min });
451
- svc.state = 'starting';
452
- svc.spawnAt = Date.now();
453
- // childCount goes as a CLI option, NOT env: nx executes tasks via its
454
- // daemon, whose environment is not the gateway's — env vars get lost.
455
- const proc = (0, node_child_process_1.spawn)('yarn', ['nx', 'run', target, `--childCount=${min}`], {
456
- cwd: context.root,
457
- detached: true,
458
- // NX_DAEMON=false keeps the serve task IN this spawned process group.
459
- // With the daemon on, Nx hands the long-running task to the persistent
460
- // daemon (a DIFFERENT group), so the later process-group kill reaps only
461
- // the `yarn nx` shim and orphans the devserver + pod tree — the classic
462
- // "idle-kill leaves live pods" leak. (--childCount as a CLI arg already
463
- // avoids the env-loss the daemon was relied on for.)
464
- env: { ...process.env, NX_DAEMON: 'false' },
465
- stdio: ['ignore', 'pipe', 'pipe'],
466
- });
467
- svc.lastErrors = [];
468
- svc.buildFailed = false;
469
- const sniff = (d) => {
470
- const text = d.toString();
471
- for (const line of text.split('\n')) {
472
- if (/ERROR in |error TS\d+|Found \d+ error|Unhandled 'error' event|EADDRINUSE/.test(line)) {
473
- // eslint-disable-next-line no-control-regex -- strip ANSI color codes from child output
474
- svc.lastErrors.push(line.replace(/\x1b\[[0-9;]*m/g, '').trim());
475
- if (svc.lastErrors.length > 12)
476
- svc.lastErrors.shift();
477
- svc.buildFailed = true;
478
- emit('error', `service error: ${svc.project}`, `cluster-service:${svc.project}`, { kind: 'service-error', project: svc.project, line: svc.lastErrors[svc.lastErrors.length - 1] });
479
- }
480
- if (/No typescript errors found|webpack compiled successfully|Child#\d+ ready/.test(line))
481
- svc.buildFailed = false;
482
- }
483
- };
484
- proc.stdout?.on('data', (d) => { if (stopped)
485
- return; sniff(d); process.stdout.write(`[${svc.project}] ${d}`); });
486
- proc.stderr?.on('data', (d) => { if (stopped)
487
- return; sniff(d); process.stderr.write(`[${svc.project}] ${d}`); });
488
- proc.on('exit', (code) => {
489
- svc.proc = undefined;
490
- svc.state = 'down';
491
- if (stopped)
492
- return; // gateway is tearing down — its dying services' exit chatter is noise
493
- console.log(`[gateway] ${target} exited (${code})`);
494
- emit(code === 0 ? 'info' : 'warning', `service down: ${svc.project} (exit ${code})`, `cluster-service:${svc.project}`, { kind: 'service-down', project: svc.project, code });
495
- });
496
- svc.proc = proc;
497
- };
498
- const ensureUp = async (svc) => {
499
- if (scanNow(svc.pool) > 0) {
500
- if (svc.state !== 'up') {
501
- 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 });
502
- }
503
- svc.state = 'up';
504
- return true;
505
- }
506
- if (stopped)
507
- return false;
508
- if (svc.state !== 'starting' && !svc.proc)
509
- spawnService(svc); // a live spawn supervises itself — don't stack a second one (EADDRINUSE)
510
- const deadline = Date.now() + startupTimeoutMs;
511
- const failFastAt = Date.now() + 12_000; // a build error within 12s → stop waiting, report the cause
512
- while (!stopped && Date.now() < deadline) {
513
- await sleep(Math.min(700, startupTimeoutMs / 4));
514
- if (svc.buildFailed && Date.now() > failFastAt)
515
- return false;
516
- if (scanNow(svc.pool) > 0) {
517
- svc.state = 'up';
518
- svc.buildFailed = false;
519
- const durationMs = svc.spawnAt ? Date.now() - svc.spawnAt : undefined;
520
- console.log(`[gateway] ${svc.project} up (${svc.pool.socks.length} pods${durationMs ? `, cold start ${durationMs}ms` : ''})`);
521
- 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 });
522
- return true;
523
- }
524
- if (svc.state === 'down')
525
- return false; // spawn died
526
- }
527
- return false;
528
- };
529
- const scaleUp = (svc) => {
530
- const max = svc.decl.maxPods ?? svc.decl.minPods ?? 1;
531
- const cur = svc.pool.socks.length;
532
- const admin = svc.decl.adminUrl ?? (svc.decl.port ? `http://localhost:${svc.decl.port}` : undefined);
533
- if (!admin || cur === 0 || cur >= max || svc.inflight <= cur)
534
- return;
535
- if (Date.now() - svc.lastScaleAt < 5000)
536
- return;
537
- svc.lastScaleAt = Date.now();
538
- const mod = admin.startsWith('https')
539
- ? // eslint-disable-next-line @typescript-eslint/no-require-imports
540
- require('node:https')
541
- : http;
542
- const r = mod.request(`${admin}/webpack/scale`, { method: 'POST', rejectUnauthorized: false }, (rs) => rs.resume());
543
- r.on('error', () => undefined);
544
- r.end(JSON.stringify({ pods: cur + 1 }));
545
- console.log(`[gateway] autoscale ${svc.project}: ${cur} → ${cur + 1} pods (inflight ${svc.inflight})`);
546
- 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 });
547
- };
548
- // Idle sweeper: kill gateway-spawned lazy services after idleKillSec.
549
- const killSpawned = (svc, why) => {
550
- if (!svc.proc)
551
- return;
552
- console.log(`[gateway] ${svc.project} ${why} — scaling to zero.`);
553
- emit('info', `idle kill: ${svc.project} (${why})`, `cluster-service:${svc.project}`, { kind: 'idle-kill', project: svc.project, why });
554
- const dying = svc.proc;
555
- // Detach the corpse's handlers BEFORE killing: a dying webpack/nx flushes
556
- // buffered stdout/stderr as it exits, and those `sniff` callbacks would
557
- // keep mutating svc.lastErrors/buildFailed (phantom build failures) — fatal
558
- // if a fresh request re-spawns this same svc while the corpse still flushes.
559
- try {
560
- dying.stdout?.removeAllListeners('data');
561
- dying.stderr?.removeAllListeners('data');
562
- dying.removeAllListeners('exit');
563
- }
564
- catch { /* ignore */ }
565
- try {
566
- process.kill(-dying.pid, 'SIGTERM');
567
- }
568
- catch {
569
- try {
570
- dying.kill('SIGTERM');
571
- }
572
- catch { /* gone */ }
573
- }
574
- svc.proc = undefined;
575
- svc.state = 'down';
576
- // children die with the tree but their sock FILES survive — remove them
577
- // so the canvas and the proxy never see ghost pods.
578
- setTimeout(() => {
579
- try {
580
- for (const f of fs.readdirSync(svc.pool.dir)) {
581
- if (f.endsWith('.sock'))
582
- fs.unlinkSync(path.join(svc.pool.dir, f));
583
- }
584
- }
585
- catch { /* gone */ }
586
- svc.pool.socks = [];
587
- svc.pool.scannedAt = 0;
588
- }, 1200).unref?.();
589
- };
590
- const sweeper = setInterval(() => {
591
- for (const svc of services) {
592
- const idle = svc.decl.idleKillSec;
593
- if (!svc.proc || !idle || svc.inflight > 0)
594
- continue;
595
- if (Date.now() - svc.lastUsed > idle * 1000)
596
- killSpawned(svc, `idle ${idle}s`);
597
- }
598
- }, options.sweepMs ?? 10_000);
599
- sweeper.unref();
600
- // Eager services boot with the gateway.
601
- for (const svc of services) {
602
- if (svc.decl.eager)
603
- void ensureUp(svc);
604
- }
605
- // --- per-service api keys (be-security: the gateway is the trust boundary) --
606
- // Every pod service gets a stable key in {cacheDir}/service-keys.json.
607
- // Public routes: the gateway injects the TARGET's key as x-internal-token —
608
- // pods are only reachable through it (unix sockets aren't on the network).
609
- // internal.<host> routes: the CALLER must present its own key as x-api-key;
610
- // the gateway validates it, stamps x-api-key-id (caller identity, for audit
611
- // and lens traces) and forwards with the target's x-internal-token.
612
- const keysDir = path.join(context.root, 'node_modules', '.cache', 'davnx-webpack');
613
- const keysPath = path.join(keysDir, 'service-keys.json');
614
- let serviceKeys = {};
615
- try {
616
- serviceKeys = JSON.parse(fs.readFileSync(keysPath, 'utf8'));
617
- }
618
- catch { /* first run */ }
619
- let keysDirty = false;
620
- for (const svc of services) {
621
- if (!serviceKeys[svc.project]) {
622
- serviceKeys[svc.project] = (0, node_crypto_1.randomBytes)(16).toString('hex');
623
- keysDirty = true;
624
- }
625
- }
626
- if (keysDirty) {
627
- try {
628
- fs.mkdirSync(keysDir, { recursive: true });
629
- fs.writeFileSync(keysPath, JSON.stringify(serviceKeys, null, 2));
630
- }
631
- catch { /* ro fs */ }
632
- }
633
- const keyToProject = new Map(Object.entries(serviceKeys).map(([p, k]) => [k, p]));
634
- // --- middleware (the JWT seam; internal.* routes skip it) -------------------
635
- let middleware;
636
- if (options.middleware) {
637
- // eslint-disable-next-line @typescript-eslint/no-require-imports
638
- const mod = require(path.resolve(context.root, options.middleware));
639
- middleware = (mod.default ?? mod);
640
- }
641
- // --- routing ------------------------------------------------------------------
642
- // The matcher is SHARED with the production gateway (one routing brain, no
643
- // drift — see manifest.ts `matchRoute` for the rules). Dev's Route carries
644
- // pod/svc fields too; matchRoute only reads host/prefix.
645
- const match = (req) => (0, manifest_1.matchRoute)(routes, (req.headers.host || '').split(':')[0], req.url ?? '/');
646
- // secure:false — upstreams may terminate TLS with the same local CA.
647
- // proxyTimeout — a pod that accepts the socket but never responds would
648
- // otherwise pin svc.inflight forever (blocking idle-kill, skewing autoscale)
649
- // and leave the gateway-request trace open; the timeout fires the proxy
650
- // error path → 502 → res 'close' → inflight decrements + trace finishes.
651
- // Keep-alive to upstreams (pods over unix sockets AND TCP apps). Without an
652
- // agent http-proxy opens a fresh connection per request — a connect per call
653
- // that ~halves throughput and adds latency. One pooled agent reuses sockets
654
- // (keyed by socketPath / host:port); https targets get the verify-skipping
655
- // https agent. Picked per request by target in `forward`/`upgrade`.
656
- // NB: a local `const https` (TLS-on flag) shadows the module here, so the
657
- // https agent is built via require — matching this file's node:https usage.
658
- const agentOpts = { keepAlive: true, keepAliveMsecs: 30_000, maxSockets: 1024, maxFreeSockets: 256 };
659
- const httpAgent = new http.Agent(agentOpts);
660
- // eslint-disable-next-line @typescript-eslint/no-require-imports
661
- const httpsAgent = new (require('node:https').Agent)(agentOpts);
662
- const agentForTarget = (target) => typeof target === 'string' && target.startsWith('https:') ? httpsAgent : httpAgent;
663
- const proxy = httpProxy.createProxyServer({ xfwd: true, secure: false, proxyTimeout: 120_000, agent: httpAgent });
664
- // Surface WHICH pod served the request (round-robin made visible).
665
- proxy.on('proxyRes', (proxyRes, req) => {
666
- const pod = req.__lensmcpPod;
667
- if (pod)
668
- proxyRes.headers['x-lensmcp-pod'] = pod;
669
- // The gateway owns the edge response: drop the upstream framework leak
670
- // (e.g. NestJS/Express `x-powered-by`) and brand the hop as the LensMCP gateway.
671
- delete proxyRes.headers['x-powered-by'];
672
- proxyRes.headers['server'] = 'LensMCP';
673
- });
674
- // Aggregated host→service traffic edges, flushed to the lens periodically —
675
- // the request-derived topology (nodes/edges appear from observed traffic).
676
- const edges = new Map();
677
- const recordEdge = (route, ms, status, caller) => {
678
- const host = route.host ?? '(default)';
679
- const key = (caller ? caller + '⇒' : '') + host + '→' + route.project;
680
- const e = edges.get(key) ?? { host, project: route.project, caller, count: 0, totalMs: 0, errors: 0 };
681
- e.count += 1;
682
- e.totalMs += ms;
683
- if (status >= 500 || status === 0)
684
- e.errors += 1;
685
- edges.set(key, e);
686
- };
687
- const edgeFlusher = setInterval(() => {
688
- for (const e of edges.values()) {
689
- const avgMs = Math.round(e.totalMs / e.count);
690
- 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 });
691
- }
692
- edges.clear();
693
- }, options.trafficFlushMs ?? 5000);
694
- edgeFlusher.unref();
695
- const forward = async (route, req, res) => {
696
- const startedAt = Date.now();
697
- const trace = req.__lensmcpTrace;
698
- res.on('close', () => {
699
- recordEdge(route, Date.now() - startedAt, res.statusCode ?? 0, req.__lensmcpCaller);
700
- finishTrace(trace, route.project, res.statusCode ?? 0, {
701
- ...(req.__lensmcpPod ? { pod: req.__lensmcpPod } : {}),
702
- ...(req.__lensmcpCaller ? { caller: req.__lensmcpCaller } : {}),
703
- });
704
- });
705
- if (route.prependPrefix && !req.url?.startsWith(route.prependPrefix)) {
706
- req.url = route.prependPrefix + (req.url ?? '/');
707
- }
708
- if (route.pool) {
709
- const svc = route.svc;
710
- if (svc) {
711
- svc.lastUsed = Date.now();
712
- svc.inflight += 1;
713
- res.on('close', () => { svc.inflight -= 1; svc.lastUsed = Date.now(); });
714
- }
715
- let sock = pickSock(route.pool, scanTtlMs);
716
- if (!sock && svc) {
717
- // scale from zero: start the service, buffer this request until pods are up
718
- traceStep(trace, 'cold-start', { project: route.project });
719
- if (await ensureUp(svc))
720
- sock = pickSock(route.pool, scanTtlMs);
721
- }
722
- if (svc)
723
- scaleUp(svc);
724
- if (!sock) {
725
- const errs = svc?.lastErrors?.length ? ` Service errors: ${svc.lastErrors.join('; ')}` : '';
726
- sendError(res, req, 503, 'unavailable', `Service ${route.project} is not reachable (no pods in ${route.pool.dir}).${errs} The watcher recovers and the next request retries automatically.`);
727
- return;
728
- }
729
- req.__lensmcpPod = path.basename(sock, '.sock');
730
- traceStep(trace, 'pod-select', { pod: path.basename(sock, '.sock'), pods: route.pool.socks.length, strategy: 'round-robin' });
731
- // NO autoRewrite for a socketPath target: it's an OBJECT, and http-proxy's setRedirectHostRewrite
732
- // does url.parse(options.target) on ANY 3xx response — which throws ("url must be a string, received
733
- // Object") and CRASHES the gateway the instant a pod returns a redirect (e.g. the auth IdP's
734
- // /authorize → login). A socket target has no host to rewrite anyway, and pods emit ABSOLUTE
735
- // external redirect URLs, so no rewrite is needed.
736
- proxy.web(req, res, { target: { socketPath: sock }, agent: httpAgent }, (err) => {
737
- route.pool.socks = route.pool.socks.filter((s) => s !== sock); // drop dead pod until rescan
738
- route.pool.idx = -1; // removing an element shifts indices — reset so the next pick walks cleanly from 0
739
- try {
740
- fs.unlinkSync(sock);
741
- }
742
- catch { /* already gone */ } // a refused sock is a GHOST file — remove it so rescans stay truthful
743
- console.error(`[gateway] pod ${path.basename(sock)} of ${route.project}:`, err.message);
744
- sendError(res, req, 502, 'unavailable', `Pod ${path.basename(sock)} of ${route.project} unavailable — retry.`);
745
- });
746
- return;
747
- }
748
- // autoRewrite ONLY for a string (external URL) target — a non-string target crashes
749
- // setRedirectHostRewrite's url.parse(options.target) on a 3xx (see the socketPath case above).
750
- proxy.web(req, res, { target: route.target, autoRewrite: typeof route.target === 'string', changeOrigin: true, agent: agentForTarget(route.target) }, (err) => {
751
- console.error(`[gateway] upstream ${route.project} (${route.target}):`, err.message);
752
- sendError(res, req, 502, 'unavailable', `Upstream ${route.project} unavailable.`);
753
- });
754
- };
755
- // The gateway OWNS these headers — strip any client-supplied copy at ingress so identity
756
- // + trust can only originate from the gateway (mirrors the prod gateway's stripTrust).
757
- const GATEWAY_OWNED_HEADERS = ['x-internal-token', 'x-api-key-id', 'x-user-id', 'x-tenant-id', 'x-user-roles', 'x-user-permissions'];
758
- const cors = (req, res) => {
759
- const origin = String(req.headers.origin ?? '*');
760
- res.setHeader('access-control-allow-origin', origin);
761
- res.setHeader('access-control-allow-credentials', 'true');
762
- res.setHeader('vary', 'Origin');
763
- };
764
- const handler = (req, res) => {
765
- // STRIP client-supplied trust headers before anything else: the gateway is
766
- // the ONLY party allowed to assert `x-internal-token` (came through me) and
767
- // `x-api-key-id` (validated caller identity). A client that sends either
768
- // must not have it forwarded — the gateway re-stamps them below where
769
- // earned. Without this, a keyless/TCP route would leak a spoofed token and
770
- // any public route would forward a spoofed caller id to downstream audit.
771
- for (const h of GATEWAY_OWNED_HEADERS)
772
- delete req.headers[h];
773
- // Mint a trace id for EVERY request (not just lens-traced ones): it's forwarded to the pod (adopted as
774
- // its traceId) and returned on the response — so any request is traceable end-to-end.
775
- (0, gateway_errors_1.ensureRequestId)(req);
776
- const trace = beginTrace(req);
777
- if (trace) {
778
- traceStep(trace, 'received', { host: trace.host, method: trace.method, url: trace.url });
779
- req.__lensmcpTrace = trace;
780
- }
781
- const route = match(req);
782
- if (!route) {
783
- // gateway-owned endpoints are cross-origin by design (the app calls
784
- // api.<domain>/health from app.<domain>) — answer CORS here; routed
785
- // paths inherit CORS from the services themselves.
786
- cors(req, res);
787
- if (req.method === 'OPTIONS') {
788
- res.writeHead(204, {
789
- 'access-control-allow-methods': 'GET,HEAD,OPTIONS',
790
- 'access-control-allow-headers': String(req.headers['access-control-request-headers'] ?? '*'),
791
- });
792
- res.end();
793
- return;
794
- }
795
- // Host-root infra: when a host only has PATH-mounted services (pure
796
- // microservices under api.<domain>/{prefix}), the gateway itself owns
797
- // the root — health aggregation + service discovery, like a real edge.
798
- const host = (req.headers.host || '').split(':')[0];
799
- const mounted = routes.filter((r) => r.host && (0, manifest_1.hostMatches)(r.host, host) && r.prefix);
800
- const url = (req.url ?? '/').split('?')[0];
801
- if (mounted.length > 0 && (url === '/health' || url === '/healthz')) {
802
- const services = mounted.map((r) => ({
803
- project: r.project,
804
- prefix: r.prefix,
805
- pods: r.pool ? (pickSock(r.pool, 0), r.pool.socks.length) : undefined,
806
- state: r.svc?.state ?? (r.target ? 'up' : 'unknown'),
807
- }));
808
- const ok = services.every((s) => s.state !== 'down' || true);
809
- res.writeHead(ok ? 200 : 503, { 'content-type': 'application/json' });
810
- res.end(JSON.stringify({ status: 'ok', host, services }, null, 2));
811
- return;
812
- }
813
- if (mounted.length > 0 && url === '/.well-known/services') {
814
- res.writeHead(200, { 'content-type': 'application/json' });
815
- res.end(JSON.stringify({ host, services: mounted.map((r) => ({ prefix: r.prefix, project: r.project, internal: !!r.internal })) }, null, 2));
816
- return;
817
- }
818
- sendError(res, req, 404, 'not_found', mounted.length > 0
819
- ? `No service mounted at ${url}. Mounted on ${host}: ${[...new Set(mounted.map((r) => r.prefix))].join(', ')} (+ /health, /.well-known/services)`
820
- : 'No gateway route for this hostname.');
821
- return;
822
- }
823
- traceStep(trace, 'route-match', {
824
- project: route.project,
825
- ...(route.prefix ? { prefix: route.prefix } : {}),
826
- ...(route.host ? { routeHost: route.host } : {}),
827
- ...(route.internal ? { internal: true } : {}),
828
- });
829
- // CORS preflight on a ROUTED path: answer it HERE, BEFORE edge auth — an OPTIONS preflight carries
830
- // no token/credentials by spec, so the JWT (or x-api-key) enforcement below would 401 it and the
831
- // browser's CORS check fails with "No Access-Control-Allow-Origin". The matching real request
832
- // (GET/POST with the token) still goes through auth normally; the service supplies CORS on it.
833
- if (req.method === 'OPTIONS') {
834
- cors(req, res);
835
- res.writeHead(204, {
836
- 'access-control-allow-methods': 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS',
837
- 'access-control-allow-headers': String(req.headers['access-control-request-headers'] ?? '*'),
838
- });
839
- res.end();
840
- return;
841
- }
842
- // CORS on EVERY routed response — including the 401/403/5xx the gateway generates ITSELF below (edge
843
- // auth, authz, proxy errors). Without this, a gateway-generated error short-circuits before the upstream
844
- // service (which carries CORS on success), so the browser sees the error WITHOUT an
845
- // Access-Control-Allow-Origin and reports a CORS failure that MASKS the real status (e.g. a 401 the app
846
- // should handle by re-authing). On the success path the upstream's CORS header overwrites this one
847
- // (http-proxy setHeader), so there's no duplicate. The gateway is the edge CORS authority.
848
- cors(req, res);
849
- if (route.internal) {
850
- // service-to-service: validate the CALLER's api key at the boundary
851
- const presented = String(req.headers['x-api-key'] ?? '');
852
- const caller = keyToProject.get(presented);
853
- if (!caller) {
854
- traceStep(trace, 'auth', { mode: 'x-api-key', ok: false });
855
- finishTrace(trace, route.project, 401);
856
- sendError(res, req, 401, 'unauthorized', 'Invalid or missing x-api-key for internal route.');
857
- return;
858
- }
859
- req.headers['x-api-key-id'] = caller; // audit + lens attribution
860
- req.__lensmcpCaller = caller;
861
- delete req.headers['x-api-key'];
862
- traceStep(trace, 'auth', { mode: 'x-api-key', ok: true, caller });
863
- }
864
- else {
865
- // Edge auth (verify the end-user JWT + inject trusted identity + enforce per-route rules),
866
- // mirroring the prod gateway. Only enforced when the service declares `cluster.auth`
867
- // (opt-in migration — un-migrated services stay open, as before).
868
- const policy = route.auth;
869
- if (policy) {
870
- const url0 = (req.url ?? '/').split('?')[0];
871
- const method = (req.method ?? 'GET').toUpperCase();
872
- const resolved = (0, manifest_1.authRuleFor)(policy, url0, method);
873
- if (resolved.mode === 'jwt') {
874
- const claims = verifyDevJwt(req);
875
- if (!claims) {
876
- traceStep(trace, 'auth', { mode: 'jwt', ok: false });
877
- finishTrace(trace, route.project, 401);
878
- sendError(res, req, 401, 'unauthorized', 'Unauthorized: invalid or missing token');
879
- return;
880
- }
881
- if (resolved.rule && !(0, manifest_1.evalRule)(resolved.rule, devAttrs(req, claims))) {
882
- traceStep(trace, 'authz', { ok: false });
883
- finishTrace(trace, route.project, 403);
884
- sendError(res, req, 403, 'forbidden', 'Forbidden: insufficient permission');
885
- return;
886
- }
887
- stampIdentity(req, claims);
888
- traceStep(trace, 'auth', { mode: 'jwt', ok: true });
889
- }
890
- else {
891
- traceStep(trace, 'auth', { mode: resolved.mode });
892
- }
893
- }
894
- else {
895
- traceStep(trace, 'auth', { mode: 'public', unconfigured: true });
896
- }
897
- // Custom user middleware (if any) runs AFTER core auth.
898
- if (middleware) {
899
- try {
900
- middleware(req);
901
- }
902
- catch (e) {
903
- traceStep(trace, 'auth', { mode: 'middleware', ok: false, reason: e.message });
904
- finishTrace(trace, route.project, 401);
905
- sendError(res, req, 401, 'unauthorized', `Gateway middleware rejected the request: ${e.message}`);
906
- return;
907
- }
908
- }
909
- }
910
- if (route.pool && serviceKeys[route.project]) {
911
- req.headers['x-internal-token'] = serviceKeys[route.project]; // pods trust the gateway
912
- }
913
- void forward(route, req, res);
914
- };
915
- const upgrade = (req, socket, head) => {
916
- // WS upgrades enforce the SAME trust model as HTTP — otherwise an
917
- // internal.<host> socket would be reachable with no x-api-key, and a
918
- // public socket would reach pods without the x-internal-token they expect.
919
- for (const h of GATEWAY_OWNED_HEADERS)
920
- delete req.headers[h];
921
- const route = match(req);
922
- if (!route) {
923
- socket.destroy();
924
- return;
925
- }
926
- if (route.internal) {
927
- const caller = keyToProject.get(String(req.headers['x-api-key'] ?? ''));
928
- if (!caller) {
929
- socket.destroy();
930
- return;
931
- } // no valid key → no internal WS
932
- req.headers['x-api-key-id'] = caller;
933
- delete req.headers['x-api-key'];
934
- }
935
- else {
936
- const policy = route.auth;
937
- if (policy) {
938
- const resolved = (0, manifest_1.authRuleFor)(policy, (req.url ?? '/').split('?')[0], (req.method ?? 'GET').toUpperCase());
939
- if (resolved.mode === 'jwt') {
940
- const claims = verifyDevJwt(req);
941
- if (!claims) {
942
- socket.destroy();
943
- return;
944
- }
945
- if (resolved.rule && !(0, manifest_1.evalRule)(resolved.rule, devAttrs(req, claims))) {
946
- socket.destroy();
947
- return;
948
- }
949
- stampIdentity(req, claims);
950
- }
951
- }
952
- if (middleware) {
953
- try {
954
- middleware(req);
955
- }
956
- catch {
957
- socket.destroy();
958
- return;
959
- }
960
- }
961
- }
962
- if (route.pool && serviceKeys[route.project]) {
963
- req.headers['x-internal-token'] = serviceKeys[route.project];
964
- }
965
- if (route.prependPrefix && !req.url?.startsWith(route.prependPrefix)) {
966
- req.url = route.prependPrefix + (req.url ?? '/');
967
- }
968
- const target = route.pool
969
- ? (() => { const s = pickSock(route.pool, scanTtlMs); return s ? { socketPath: s } : undefined; })()
970
- : route.target;
971
- if (!target) {
972
- socket.destroy();
973
- return;
974
- }
975
- proxy.ws(req, socket, head, { target, changeOrigin: !route.pool, agent: agentForTarget(target) }, () => socket.destroy());
976
- };
977
- // --- TLS + listeners ------------------------------------------------------------
978
- let pem;
979
- let caPath;
980
- if (https) {
981
- // eslint-disable-next-line @typescript-eslint/no-require-imports
982
- const basicSsl = require('../../basic-ssl');
983
- const cacheDir = path.join(context.root, 'node_modules', '.cache', 'davnx-webpack');
984
- const hosts = routes.map((r) => r.host).filter((h) => !!h);
985
- pem = basicSsl.getCertificateSync(cacheDir, 'gateway', hosts);
986
- caPath = basicSsl.caCertPath(cacheDir);
987
- }
988
- const servers = [];
989
- const boundPorts = [];
990
- for (const port of ports) {
991
- const server = pem
992
- ? // eslint-disable-next-line @typescript-eslint/no-require-imports
993
- require('node:https').createServer({ key: pem, cert: pem }, handler)
994
- : http.createServer(handler);
995
- server.on('upgrade', upgrade);
996
- server.on('error', (err) => console.error(`[gateway] port ${port}: ${err.code}`));
997
- await new Promise((resolve) => {
998
- server.listen(port, () => {
999
- const actual = server.address()?.port ?? port;
1000
- boundPorts.push(actual);
1001
- console.log(`[gateway] Listening on ${pem ? 'https' : 'http'}://localhost:${actual}`);
1002
- resolve();
1003
- });
1004
- });
1005
- servers.push(server);
1006
- }
1007
- console.log('[gateway] Routes:');
1008
- for (const r of routes) {
1009
- const dest = r.pool ? `pods@${r.pool.dir}` : r.target;
1010
- console.log(`[gateway] ${r.host ?? '(default)'} → ${dest}${r.prependPrefix ? ` (+${r.prependPrefix})` : ''}${r.internal ? ' [internal: no middleware]' : ''} [${r.project}]`);
1011
- }
1012
- if (caPath)
1013
- console.log(`[gateway] CA: ${caPath} — one-time setup via the trust executor.`);
1014
- emit('info', `gateway up: ${routes.length} routes on ${boundPorts.join('/')}`, 'cluster-gateway', {
1015
- kind: 'gateway-up',
1016
- ports: boundPorts,
1017
- routes: routes.map((r) => ({ host: r.host ?? '(default)', project: r.project, mode: r.pool ? 'pods' : 'tcp', internal: !!r.internal, prependPrefix: r.prependPrefix })),
1018
- });
1019
- const stop = async () => {
1020
- if (stopped)
1021
- return;
1022
- stopped = true;
1023
- clearInterval(edgeFlusher);
1024
- clearInterval(sweeper);
1025
- emit('info', 'gateway down', 'cluster-gateway', { kind: 'gateway-down' });
1026
- for (const svc of services)
1027
- killSpawned(svc, 'gateway shutdown');
1028
- // Reap the lens-mode managed children (dashboard + MCP singletons + each
1029
- // `lens:true` app's Vite/capture) — same teardown as the pod services.
1030
- for (const c of lensChildren) {
1031
- try {
1032
- c.kill('SIGTERM');
1033
- }
1034
- catch { /* already gone */ }
1035
- }
1036
- await Promise.all(servers.map((s) => new Promise((r) => s.close(() => r()))));
1037
- try {
1038
- proxy.close?.();
1039
- }
1040
- catch { /* ignore */ }
1041
- httpAgent.destroy();
1042
- httpsAgent.destroy(); // release pooled upstream sockets
1043
- };
1044
- return { ports: boundPorts, routes, services, stop };
1045
- }
16
+ var manifest_1 = require("./manifest"); // back-compat re-export
17
+ Object.defineProperty(exports, "hostMatches", { enumerable: true, get: function () { return manifest_1.hostMatches; } });
18
+ var server_1 = require("./runtime/server");
19
+ Object.defineProperty(exports, "startGateway", { enumerable: true, get: function () { return server_1.startGateway; } });
20
+ var dev_auth_1 = require("./runtime/dev-auth");
21
+ Object.defineProperty(exports, "verifyDevJwt", { enumerable: true, get: function () { return dev_auth_1.verifyDevJwt; } });
22
+ Object.defineProperty(exports, "stampIdentity", { enumerable: true, get: function () { return dev_auth_1.stampIdentity; } });
23
+ var scope_1 = require("./runtime/scope");
24
+ Object.defineProperty(exports, "baseDomainOf", { enumerable: true, get: function () { return scope_1.baseDomainOf; } });
25
+ Object.defineProperty(exports, "readLensScope", { enumerable: true, get: function () { return scope_1.readLensScope; } });
26
+ var discovery_1 = require("./runtime/discovery");
27
+ Object.defineProperty(exports, "pickSock", { enumerable: true, get: function () { return discovery_1.pickSock; } });
28
+ Object.defineProperty(exports, "discoverRoutes", { enumerable: true, get: function () { return discovery_1.discoverRoutes; } });