@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.
- package/executors/gateway/gateway.lib.d.ts +15 -167
- package/executors/gateway/gateway.lib.d.ts.map +1 -1
- package/executors/gateway/gateway.lib.js +24 -1041
- package/executors/gateway/main.prod-gateway.js +74 -2
- package/executors/gateway/prod-gateway.lib.d.ts +19 -151
- package/executors/gateway/prod-gateway.lib.d.ts.map +1 -1
- package/executors/gateway/prod-gateway.lib.js +16 -863
- package/executors/gateway/prod-runtime/access-log.d.ts +9 -0
- package/executors/gateway/prod-runtime/access-log.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/access-log.js +24 -0
- package/executors/gateway/prod-runtime/app.d.ts +23 -0
- package/executors/gateway/prod-runtime/app.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/app.js +123 -0
- package/executors/gateway/prod-runtime/auth.d.ts +30 -0
- package/executors/gateway/prod-runtime/auth.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/auth.js +51 -0
- package/executors/gateway/prod-runtime/cors.d.ts +19 -0
- package/executors/gateway/prod-runtime/cors.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/cors.js +40 -0
- package/executors/gateway/prod-runtime/edge.d.ts +16 -0
- package/executors/gateway/prod-runtime/edge.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/edge.js +65 -0
- package/executors/gateway/prod-runtime/handler.d.ts +32 -0
- package/executors/gateway/prod-runtime/handler.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/handler.js +226 -0
- package/executors/gateway/prod-runtime/hooks.d.ts +20 -0
- package/executors/gateway/prod-runtime/hooks.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/hooks.js +42 -0
- package/executors/gateway/prod-runtime/observability.d.ts +23 -0
- package/executors/gateway/prod-runtime/observability.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/observability.js +125 -0
- package/executors/gateway/prod-runtime/rollout.d.ts +18 -0
- package/executors/gateway/prod-runtime/rollout.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/rollout.js +103 -0
- package/executors/gateway/prod-runtime/routing.d.ts +4 -0
- package/executors/gateway/prod-runtime/routing.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/routing.js +40 -0
- package/executors/gateway/prod-runtime/server.d.ts +3 -0
- package/executors/gateway/prod-runtime/server.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/server.js +79 -0
- package/executors/gateway/prod-runtime/trust.d.ts +12 -0
- package/executors/gateway/prod-runtime/trust.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/trust.js +32 -0
- package/executors/gateway/prod-runtime/types.d.ts +241 -0
- package/executors/gateway/prod-runtime/types.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/types.js +2 -0
- package/executors/gateway/prod-runtime/upgrade.d.ts +21 -0
- package/executors/gateway/prod-runtime/upgrade.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/upgrade.js +116 -0
- package/executors/gateway/prod-runtime/upstream.d.ts +13 -0
- package/executors/gateway/prod-runtime/upstream.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/upstream.js +21 -0
- package/executors/gateway/runtime/auth.d.ts +8 -0
- package/executors/gateway/runtime/auth.d.ts.map +1 -0
- package/executors/gateway/runtime/auth.js +60 -0
- package/executors/gateway/runtime/dev-auth.d.ts +9 -0
- package/executors/gateway/runtime/dev-auth.d.ts.map +1 -0
- package/executors/gateway/runtime/dev-auth.js +79 -0
- package/executors/gateway/runtime/discovery.d.ts +17 -0
- package/executors/gateway/runtime/discovery.d.ts.map +1 -0
- package/executors/gateway/runtime/discovery.js +111 -0
- package/executors/gateway/runtime/edge.d.ts +31 -0
- package/executors/gateway/runtime/edge.d.ts.map +1 -0
- package/executors/gateway/runtime/edge.js +47 -0
- package/executors/gateway/runtime/handler.d.ts +24 -0
- package/executors/gateway/runtime/handler.d.ts.map +1 -0
- package/executors/gateway/runtime/handler.js +165 -0
- package/executors/gateway/runtime/hooks.d.ts +23 -0
- package/executors/gateway/runtime/hooks.d.ts.map +1 -0
- package/executors/gateway/runtime/hooks.js +55 -0
- package/executors/gateway/runtime/lens-children.d.ts +13 -0
- package/executors/gateway/runtime/lens-children.d.ts.map +1 -0
- package/executors/gateway/runtime/lens-children.js +136 -0
- package/executors/gateway/runtime/lifecycle.d.ts +13 -0
- package/executors/gateway/runtime/lifecycle.d.ts.map +1 -0
- package/executors/gateway/runtime/lifecycle.js +179 -0
- package/executors/gateway/runtime/observability.d.ts +17 -0
- package/executors/gateway/runtime/observability.d.ts.map +1 -0
- package/executors/gateway/runtime/observability.js +107 -0
- package/executors/gateway/runtime/proxy.d.ts +20 -0
- package/executors/gateway/runtime/proxy.d.ts.map +1 -0
- package/executors/gateway/runtime/proxy.js +113 -0
- package/executors/gateway/runtime/scope.d.ts +16 -0
- package/executors/gateway/runtime/scope.d.ts.map +1 -0
- package/executors/gateway/runtime/scope.js +68 -0
- package/executors/gateway/runtime/server.d.ts +3 -0
- package/executors/gateway/runtime/server.d.ts.map +1 -0
- package/executors/gateway/runtime/server.js +168 -0
- package/executors/gateway/runtime/service-keys.d.ts +11 -0
- package/executors/gateway/runtime/service-keys.d.ts.map +1 -0
- package/executors/gateway/runtime/service-keys.js +49 -0
- package/executors/gateway/runtime/types.d.ts +234 -0
- package/executors/gateway/runtime/types.d.ts.map +1 -0
- package/executors/gateway/runtime/types.js +2 -0
- package/executors/gateway/runtime/upgrade.d.ts +22 -0
- package/executors/gateway/runtime/upgrade.d.ts.map +1 -0
- package/executors/gateway/runtime/upgrade.js +63 -0
- 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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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; } });
|