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