@lensmcp/cluster 1.18.4 → 1.18.7

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 (64) hide show
  1. package/basic-ssl.js +1 -241
  2. package/build-scope-patterns.js +1 -40
  3. package/create-webpack-dev.js +1 -186
  4. package/create-webpack-prod.js +1 -169
  5. package/executors/build/build.impl.js +1 -98
  6. package/executors/gateway/gateway-errors.js +1 -43
  7. package/executors/gateway/gateway.impl.js +1 -53
  8. package/executors/gateway/gateway.lib.js +1 -29
  9. package/executors/gateway/health-check.js +1 -66
  10. package/executors/gateway/jwks-verify.js +1 -121
  11. package/executors/gateway/main.prod-gateway.js +2 -573
  12. package/executors/gateway/main.rollout.js +11 -117
  13. package/executors/gateway/manifest.js +1 -374
  14. package/executors/gateway/metrics.js +1 -56
  15. package/executors/gateway/otel-tracing.js +1 -74
  16. package/executors/gateway/prod-gateway.lib.js +1 -22
  17. package/executors/gateway/prod-runtime/access-log.js +1 -24
  18. package/executors/gateway/prod-runtime/app.js +1 -123
  19. package/executors/gateway/prod-runtime/auth.js +1 -51
  20. package/executors/gateway/prod-runtime/cors.js +1 -40
  21. package/executors/gateway/prod-runtime/edge.js +1 -65
  22. package/executors/gateway/prod-runtime/handler.js +1 -226
  23. package/executors/gateway/prod-runtime/hooks.js +1 -42
  24. package/executors/gateway/prod-runtime/observability.js +1 -125
  25. package/executors/gateway/prod-runtime/rollout.js +1 -103
  26. package/executors/gateway/prod-runtime/routing.js +1 -40
  27. package/executors/gateway/prod-runtime/server.js +1 -79
  28. package/executors/gateway/prod-runtime/trust.js +1 -32
  29. package/executors/gateway/prod-runtime/types.js +1 -2
  30. package/executors/gateway/prod-runtime/upgrade.js +4 -116
  31. package/executors/gateway/prod-runtime/upstream.js +1 -21
  32. package/executors/gateway/providers-prod.js +1 -232
  33. package/executors/gateway/rate-limit.js +2 -75
  34. package/executors/gateway/registry-source.js +1 -131
  35. package/executors/gateway/rollout-ops.js +2 -167
  36. package/executors/gateway/runtime/auth.js +1 -64
  37. package/executors/gateway/runtime/chooser.js +12 -45
  38. package/executors/gateway/runtime/control.js +1 -128
  39. package/executors/gateway/runtime/dev-auth.js +1 -108
  40. package/executors/gateway/runtime/discovery.js +1 -123
  41. package/executors/gateway/runtime/edge.js +1 -47
  42. package/executors/gateway/runtime/handler.js +1 -183
  43. package/executors/gateway/runtime/hooks.js +1 -55
  44. package/executors/gateway/runtime/lens-children.js +1 -651
  45. package/executors/gateway/runtime/lifecycle.js +3 -842
  46. package/executors/gateway/runtime/observability.js +2 -148
  47. package/executors/gateway/runtime/pod-env.js +2 -89
  48. package/executors/gateway/runtime/proxy.js +1 -457
  49. package/executors/gateway/runtime/route-registry.js +1 -72
  50. package/executors/gateway/runtime/scope.js +1 -117
  51. package/executors/gateway/runtime/server.js +3 -487
  52. package/executors/gateway/runtime/service-keys.js +1 -49
  53. package/executors/gateway/runtime/types.js +1 -151
  54. package/executors/gateway/runtime/upgrade.js +1 -71
  55. package/executors/gateway/runtime/workspace-registry.js +1 -99
  56. package/executors/gateway/ssrf-guard.js +1 -190
  57. package/executors/serve/serve.impl.js +1 -280
  58. package/executors/trust/trust.impl.js +4 -162
  59. package/gateway.js +1 -35
  60. package/index.js +1 -16
  61. package/main.devserver.js +10 -1117
  62. package/package.json +4 -3
  63. package/tsgo-check-plugin.js +4 -364
  64. package/typecheck-bus.js +4 -256
@@ -1,457 +1 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createProxy = createProxy;
4
- const tslib_1 = require("tslib");
5
- /**
6
- * The forwarding layer: one pooled `http-proxy` instance (keep-alive agents) and
7
- * the `forward` that drives it — pod round-robin with scale-from-zero/autoscale,
8
- * or a TCP upstream. Owns the edge-response branding (`x-lensmcp-pod`, stripping
9
- * `x-powered-by`, `server: LensMCP`).
10
- */
11
- const fs = tslib_1.__importStar(require("node:fs"));
12
- const http = tslib_1.__importStar(require("node:http"));
13
- const path = tslib_1.__importStar(require("node:path"));
14
- const discovery_1 = require("./discovery");
15
- const edge_1 = require("./edge");
16
- const types_1 = require("./types");
17
- /** A CONNECTION-class upstream error — the pod/dev-server is momentarily unreachable (restarting its
18
- * internal server on a config change, or warming up), NOT an application response. Only these are
19
- * retried by the self-heal; a real 5xx from the app has already streamed a response and never lands here. */
20
- function isConnError(err) {
21
- const code = err.code ?? '';
22
- return (code === 'ECONNREFUSED' ||
23
- code === 'ECONNRESET' ||
24
- code === 'ETIMEDOUT' ||
25
- code === 'EPIPE' ||
26
- code === 'EHOSTUNREACH' ||
27
- code === 'ENOENT' ||
28
- /ECONNREFUSED|ECONNRESET|socket hang up|EPIPE/i.test(err.message));
29
- }
30
- /** Hop-by-hop headers (RFC 7230 §6.1) — never forwarded upstream, never copied onto the edge
31
- * response. Doubles as the set of connection-specific headers that are ILLEGAL on an HTTP/2 stream
32
- * (Node's http2 layer throws on `connection`/`transfer-encoding`/… ), which is why the h2 native
33
- * forwarder below must strip them where node-http-proxy (H1-only) never had to. */
34
- const HOP_BY_HOP = new Set([
35
- 'connection', 'keep-alive', 'proxy-connection', 'proxy-authenticate', 'proxy-authorization',
36
- 'te', 'trailer', 'transfer-encoding', 'upgrade',
37
- ]);
38
- // eslint-disable-next-line @typescript-eslint/no-require-imports
39
- const httpProxy = require('http-proxy');
40
- function createProxy(rt, obs, svcLayer) {
41
- const { recordEdge, finishTrace, traceStep } = obs;
42
- // Keep-alive to upstreams (pods over unix sockets AND TCP apps). Without an
43
- // agent http-proxy opens a fresh connection per request — a connect per call
44
- // that ~halves throughput and adds latency. One pooled agent reuses sockets
45
- // (keyed by socketPath / host:port); https targets get the verify-skipping
46
- // https agent. NB: this file uses `require('node:https')` to match the source
47
- // file's pattern (the orchestrator shadows the module with a local TLS flag).
48
- const agentOpts = { keepAlive: true, keepAliveMsecs: 30_000, maxSockets: 1024, maxFreeSockets: 256 };
49
- const httpAgent = new http.Agent(agentOpts);
50
- // eslint-disable-next-line @typescript-eslint/no-require-imports
51
- const httpsAgent = new (require('node:https').Agent)(agentOpts);
52
- const agentForTarget = (target) => typeof target === 'string' && target.startsWith('https:') ? httpsAgent : httpAgent;
53
- // secure:false — upstreams may terminate TLS with the same local CA.
54
- // proxyTimeout — a pod that accepts the socket but never responds would
55
- // otherwise pin svc.inflight forever (blocking idle-kill, skewing autoscale)
56
- // and leave the gateway-request trace open; the timeout fires the proxy
57
- // error path → 502 → res 'close' → inflight decrements + trace finishes.
58
- const proxy = httpProxy.createProxyServer({ xfwd: true, secure: false, proxyTimeout: 120_000, agent: httpAgent });
59
- /**
60
- * EDGE-CANCEL PROPAGATION for the H1 (node-http-proxy) path — the H1 twin of `pipeUpstream`'s
61
- * `onEdgeClose`, and a real leak fix.
62
- *
63
- * node-http-proxy 1.18.1 propagates a client cancel through exactly ONE hook:
64
- * `req.on('aborted', () => proxyReq.abort())` (web-incoming.js). `'aborted'` is the event Node
65
- * DEPRECATED in v16 in favour of `'close'`, and it does not fire here — MEASURED on Node 24 with the
66
- * real pooled-agent setup below: on an edge cancel mid-response the upstream request was never aborted
67
- * (`aborted` 0 times), the upstream response never closed, the upstream kept STREAMING forever into a
68
- * socket nobody reads, and the pooled socket stayed permanently CHECKED OUT of the agent
69
- * (`in-use=1, free=0`) — leaked, never reused, never released. Every cancelled request (an unmounted
70
- * SSE stream, an HMR reload, a navigated-away fetch) leaked one upstream socket AND pinned the pod
71
- * generating events for a client that is gone; enough of them walk the agent to `maxSockets`, after
72
- * which new requests queue behind sockets that can never come free.
73
- *
74
- * So the cancel is wired here instead, on both halves of the upstream exchange:
75
- * - `proxyReq` — cancel BEFORE the response arrives ⇒ destroy the outbound request.
76
- * - `proxyRes` — cancel MID-response ⇒ destroy the upstream response.
77
- * DESTROY, never release: a destroyed socket is dropped from the agent instead of returned to
78
- * `freeSockets`, so a half-drained socket can never be picked up by a later request. Measured after the
79
- * fix: upstream closed, `in-use=0, free=0`, and http-proxy's error callback NOT invoked (so this raises
80
- * no spurious 502 and triggers no heal retry). Each listener is removed when its upstream half closes,
81
- * so nothing accumulates on a retried request (see the leak note in `pipeUpstream`).
82
- */
83
- const propagateEdgeCancel = (upstream, res, completed) => {
84
- const onEdgeClose = () => {
85
- if (completed())
86
- return; // the response finished normally — nothing to cancel
87
- if (!upstream.destroyed)
88
- upstream.destroy();
89
- };
90
- res.once('close', onEdgeClose);
91
- upstream.once('close', () => res.removeListener('close', onEdgeClose));
92
- };
93
- proxy.on('proxyReq', (proxyReq, _req, res) => {
94
- // `writableEnded` ⇒ we already flushed the whole edge response, so this 'close' is normal completion.
95
- propagateEdgeCancel(proxyReq, res, () => res.writableEnded);
96
- });
97
- // Surface WHICH pod served the request (round-robin made visible).
98
- proxy.on('proxyRes', (proxyRes, req, res) => {
99
- const pod = req.__lensmcpPod;
100
- if (pod)
101
- proxyRes.headers['x-lensmcp-pod'] = pod;
102
- // The gateway owns the edge response: drop the upstream framework leak
103
- // (e.g. NestJS/Express `x-powered-by`) and brand the hop as the LensMCP gateway.
104
- delete proxyRes.headers['x-powered-by'];
105
- proxyRes.headers['server'] = 'LensMCP';
106
- // A mid-response cancel must stop the upstream too: `proxyRes.pipe(res)` only UNPIPES when the edge
107
- // dies (Node's `pipe` never destroys the source), which is what stranded the socket.
108
- if (res)
109
- propagateEdgeCancel(proxyRes, res, () => res.writableEnded);
110
- });
111
- // --- native HTTP/2 forwarder ------------------------------------------------
112
- // node-http-proxy is HTTP/1.1-only: it copies connection-specific headers onto the
113
- // response, which Node's http2 layer rejects. So an h2 EDGE request (a browser, once
114
- // the front door speaks h2) is forwarded here with a raw http.request to the H1
115
- // upstream (pod unix-socket or TCP dev-server) and the upstream response is piped back
116
- // into the h2 stream. It mirrors the node-http-proxy path's branding (x-lensmcp-pod,
117
- // drop x-powered-by, server:LensMCP), pod self-heal (evict + wedge timer) and TCP
118
- // heal-retry — so h2 and h1 behave identically apart from the wire framing.
119
- const pipeUpstream = (target, req, res, cbs) => {
120
- const outHeaders = {};
121
- for (const [k, v] of Object.entries(req.headers)) {
122
- if (v === undefined || k.startsWith(':') || HOP_BY_HOP.has(k))
123
- continue; // strip pseudo + hop-by-hop
124
- outHeaders[k] = v;
125
- }
126
- // x-forwarded-* parity with node-http-proxy's xfwd:true.
127
- const remote = req.socket?.remoteAddress ?? '';
128
- const priorXff = req.headers['x-forwarded-for'];
129
- outHeaders['x-forwarded-for'] = priorXff ? `${String(priorXff)}, ${remote}` : remote;
130
- outHeaders['x-forwarded-proto'] = 'https';
131
- if (!outHeaders['x-forwarded-host'])
132
- outHeaders['x-forwarded-host'] = req.headers.host ?? '';
133
- let mod = http;
134
- let opts;
135
- if (typeof target === 'object') {
136
- // pod (unix socket): no changeOrigin — the pod ignores Host (matches the h1 path).
137
- opts = { socketPath: target.socketPath, path: req.url, method: req.method, headers: outHeaders, agent: httpAgent };
138
- }
139
- else {
140
- const u = new URL(target);
141
- const isHttps = u.protocol === 'https:';
142
- // eslint-disable-next-line @typescript-eslint/no-require-imports
143
- mod = isHttps ? require('node:https') : http;
144
- outHeaders['host'] = u.host; // changeOrigin:true parity — a Vite dev server matches on Host
145
- opts = {
146
- protocol: u.protocol,
147
- hostname: u.hostname,
148
- port: u.port || (isHttps ? 443 : 80),
149
- path: req.url,
150
- method: req.method,
151
- headers: outHeaders,
152
- agent: agentForTarget(target),
153
- };
154
- }
155
- // An h2 ServerResponse whose stream faults (a RST from the client, a
156
- // `res.destroy(err)` below) emits 'error'; UNHANDLED, that throws and can
157
- // escalate to tear the whole h2 session down — the very "burst" symptom.
158
- // A no-op listener keeps the fault local to THIS stream. (Harmless on h1.)
159
- //
160
- // ONCE PER REQUEST, not per attempt. `pipeUpstream` is re-entered for every TCP heal RETRY (see
161
- // `tryTcp`), and containment is a property of the STREAM, not of one upstream attempt — so a plain
162
- // `res.on('error')` here accumulated one listener per retry. With an 8s heal window at 300ms steps
163
- // that is ~26 listeners on a single response, which Node reported as
164
- // `MaxListenersExceededWarning: 11 error listeners added to [Http2ServerResponse]` (and the twin for
165
- // [Http2ServerRequest] via the `req.on('error')` below). Each leaked closure also RETAINS the
166
- // request/response objects — a memory bug, not just log noise. The marker makes re-entry idempotent.
167
- const guard = res;
168
- if (!guard.__lensmcpErrContained) {
169
- guard.__lensmcpErrContained = true;
170
- res.on('error', () => { });
171
- }
172
- // Per-ATTEMPT listeners live on the SHARED per-request `req`/`res`, so each attempt must REMOVE its own
173
- // on the way out — otherwise a heal retry (`tryTcp`) stacks one more of each per pass (the leak above).
174
- // `upstream` is captured so the edge-close handler can be built (and detached) outside the response cb.
175
- let upstream;
176
- const onReqError = () => { upstreamReq.destroy(); };
177
- // The client canceled: stop reading the upstream so its pooled keep-alive socket isn't left flowing
178
- // (a half-drained socket is corrupt on the next reuse → would break a later request).
179
- const onEdgeClose = () => { if (upstream && !upstream.destroyed)
180
- upstream.destroy(); };
181
- const detach = () => {
182
- req.removeListener('error', onReqError);
183
- res.removeListener('close', onEdgeClose);
184
- };
185
- const upstreamReq = mod.request(opts, (upstreamRes) => {
186
- upstream = upstreamRes;
187
- cbs.onResponse();
188
- if (res.headersSent || res.writableEnded || res.destroyed) {
189
- upstreamRes.destroy();
190
- return;
191
- }
192
- const h = {};
193
- for (const [k, v] of Object.entries(upstreamRes.headers)) {
194
- if (v === undefined || HOP_BY_HOP.has(k))
195
- continue; // hop-by-hop headers are h2-illegal
196
- h[k] = v;
197
- }
198
- delete h['x-powered-by']; // drop the upstream framework leak (parity with proxyRes)
199
- h['server'] = 'LensMCP'; // brand the hop
200
- const pod = req.__lensmcpPod;
201
- if (pod)
202
- h['x-lensmcp-pod'] = pod;
203
- try {
204
- res.writeHead(upstreamRes.statusCode ?? 502, h);
205
- }
206
- catch (e) {
207
- // A header the upstream sent is illegal on an h2 stream (Node throws e.g.
208
- // ERR_HTTP2_INVALID_HEADER_VALUE). RESET only THIS stream — left unhandled the throw escapes
209
- // the request callback and can tear down the whole h2 SESSION, failing EVERY sibling stream on
210
- // the connection at once with ERR_HTTP2_PROTOCOL_ERROR (the "burst of files failed" symptom).
211
- console.error('[gateway] h2 relay: bad response header, resetting stream:', e.message);
212
- upstreamRes.destroy();
213
- if (!res.destroyed)
214
- res.destroy(e);
215
- return;
216
- }
217
- upstreamRes.pipe(res);
218
- // The upstream dies MID-BODY (a pod rolling-swap or a Vite restart resets the pooled socket): RST
219
- // this h2 stream cleanly instead of leaving it half-written — a dangling h2 stream is what Chrome
220
- // reports as ERR_HTTP2_PROTOCOL_ERROR. A premature close surfaces as 'error' OR just 'close'
221
- // ('aborted') depending on the Node version, so handle BOTH; the `writableEnded` guard makes the
222
- // normal-completion 'close' a no-op. (An unhandled 'error' on upstreamRes would also throw.)
223
- const abortEdge = () => { if (!res.writableEnded && !res.destroyed)
224
- res.destroy(); };
225
- upstreamRes.once('error', abortEdge);
226
- upstreamRes.once('close', abortEdge);
227
- res.on('close', onEdgeClose);
228
- });
229
- upstreamReq.once('error', (err) => cbs.onError(err));
230
- // ClientRequest 'close' fires exactly once however the attempt ended (completed, errored, destroyed), so
231
- // it is the one place that can un-register this attempt's listeners — and drop the closures retaining
232
- // `req`/`res`/`upstream`. For a long-lived stream it fires only when the stream ends, so `onEdgeClose`
233
- // stays armed for the whole stream (a mid-stream client cancel MUST still destroy the upstream).
234
- upstreamReq.once('close', detach);
235
- req.on('error', onReqError);
236
- // GET/HEAD/OPTIONS carry no body — end immediately; else stream the request body upstream.
237
- if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS')
238
- upstreamReq.end();
239
- else
240
- req.pipe(upstreamReq);
241
- };
242
- // The h2 twin of the pool/TCP branches in `forward` below — same pick / cold-start /
243
- // evict / wedge / heal-retry, driving `pipeUpstream` instead of node-http-proxy.
244
- const forwardNative = async (route, req, res) => {
245
- const trace = req.__lensmcpTrace;
246
- if (route.pool) {
247
- const ctl = route.svc;
248
- if (ctl) {
249
- ctl.lastUsed = Date.now();
250
- ctl.inflight += 1;
251
- res.on('close', () => { ctl.inflight -= 1; ctl.lastUsed = Date.now(); });
252
- }
253
- let sock = (0, discovery_1.pickSock)(route.pool, rt.scanTtlMs);
254
- if (!sock && ctl) {
255
- traceStep(trace, 'cold-start', { project: route.project });
256
- if (await svcLayer.ensureUp(ctl))
257
- sock = (0, discovery_1.pickSock)(route.pool, rt.scanTtlMs);
258
- }
259
- // A ROUTABLE pod is the recovery edge (see `markUp` in lifecycle.ts): `ensureUp` — which used
260
- // to be the only announcer of a service going up — is SKIPPED whenever `pickSock` already
261
- // found a socket, so a service that self-healed between requests was never announced and the
262
- // lens fold latched on its last degradation. Idempotent ⇒ a property compare per request.
263
- if (ctl && sock)
264
- svcLayer.markUp(ctl, 'observed');
265
- if (ctl)
266
- svcLayer.scaleUp(ctl);
267
- if (!sock) {
268
- const errs = ctl?.lastErrors?.length ? ` Service errors: ${ctl.lastErrors.join('; ')}` : '';
269
- (0, edge_1.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.`);
270
- return;
271
- }
272
- const podName = path.basename(sock, '.sock');
273
- req.__lensmcpPod = podName;
274
- traceStep(trace, 'pod-select', { pod: podName, pods: route.pool.socks.length, strategy: 'round-robin' });
275
- let settled = false;
276
- const evictPod = (reason) => {
277
- route.pool.socks = route.pool.socks.filter((s) => s !== sock);
278
- route.pool.idx = -1;
279
- try {
280
- fs.unlinkSync(sock);
281
- }
282
- catch { /* already gone */ }
283
- console.error(`[gateway] pod ${podName} of ${route.project}: ${reason}`);
284
- };
285
- const wedgeTimer = setTimeout(() => {
286
- if (settled || res.headersSent || res.writableEnded)
287
- return;
288
- settled = true;
289
- evictPod(`no response within ${types_1.POD_RESPONSE_TIMEOUT_MS}ms (wedged socket) — evicted + respawning`);
290
- (0, edge_1.sendError)(res, req, 502, 'unavailable', `Pod ${podName} of ${route.project} was unresponsive — evicted, retry.`);
291
- }, types_1.POD_RESPONSE_TIMEOUT_MS);
292
- wedgeTimer.unref?.();
293
- res.on('close', () => clearTimeout(wedgeTimer));
294
- pipeUpstream({ socketPath: sock }, req, res, {
295
- onResponse: () => { clearTimeout(wedgeTimer); settled = true; },
296
- onError: (err) => {
297
- clearTimeout(wedgeTimer);
298
- if (settled)
299
- return;
300
- settled = true;
301
- evictPod(err.message);
302
- (0, edge_1.sendError)(res, req, 502, 'unavailable', `Pod ${podName} of ${route.project} unavailable — retry.`);
303
- },
304
- });
305
- return;
306
- }
307
- const tcpTarget = route.target;
308
- const tcpDeadline = Date.now() + types_1.UPSTREAM_HEAL_WINDOW_MS;
309
- const tryTcp = () => {
310
- pipeUpstream(tcpTarget, req, res, {
311
- onResponse: () => { },
312
- onError: (err) => {
313
- // `!res.destroyed`: once the CLIENT is gone there is nobody to heal for — retrying would hammer a
314
- // recovering upstream for the rest of the window and then write an error into a dead stream.
315
- if (isConnError(err) && Date.now() < tcpDeadline && !res.headersSent && !res.writableEnded && !res.destroyed) {
316
- const t = setTimeout(tryTcp, types_1.UPSTREAM_HEAL_STEP_MS);
317
- t.unref?.(); // a pending heal retry must never keep the gateway process alive
318
- return;
319
- }
320
- // Past the response headers there's nothing to send an error body into — reset the stream.
321
- if (res.headersSent || res.writableEnded || res.destroyed) {
322
- if (!res.destroyed)
323
- res.destroy();
324
- return;
325
- }
326
- console.error(`[gateway] upstream ${route.project} (${String(tcpTarget)}):`, err.message);
327
- (0, edge_1.sendError)(res, req, 502, 'unavailable', `Upstream ${route.project} unavailable.`);
328
- },
329
- });
330
- };
331
- tryTcp();
332
- };
333
- const forward = async (route, req, res) => {
334
- const startedAt = Date.now();
335
- const trace = req.__lensmcpTrace;
336
- res.on('close', () => {
337
- recordEdge(route, Date.now() - startedAt, res.statusCode ?? 0, req.__lensmcpCaller);
338
- finishTrace(trace, route.project, res.statusCode ?? 0, {
339
- ...(req.__lensmcpPod ? { pod: req.__lensmcpPod } : {}),
340
- ...(req.__lensmcpCaller ? { caller: req.__lensmcpCaller } : {}),
341
- });
342
- });
343
- if (route.prependPrefix && !req.url?.startsWith(route.prependPrefix)) {
344
- req.url = route.prependPrefix + (req.url ?? '/');
345
- }
346
- // HTTP/2 edge requests (browsers, once the front door speaks h2) can't ride node-http-proxy
347
- // — forward them natively. H1 requests (curl, tools, the HMR WebSocket's sibling fetches)
348
- // keep the proven path below unchanged.
349
- if ((req.httpVersionMajor ?? 1) >= 2) {
350
- await forwardNative(route, req, res);
351
- return;
352
- }
353
- if (route.pool) {
354
- const ctl = route.svc;
355
- if (ctl) {
356
- ctl.lastUsed = Date.now();
357
- ctl.inflight += 1;
358
- res.on('close', () => { ctl.inflight -= 1; ctl.lastUsed = Date.now(); });
359
- }
360
- let sock = (0, discovery_1.pickSock)(route.pool, rt.scanTtlMs);
361
- if (!sock && ctl) {
362
- // scale from zero: start the service, buffer this request until pods are up
363
- traceStep(trace, 'cold-start', { project: route.project });
364
- if (await svcLayer.ensureUp(ctl))
365
- sock = (0, discovery_1.pickSock)(route.pool, rt.scanTtlMs);
366
- }
367
- // The recovery edge — see the twin comment in `forwardNative` above.
368
- if (ctl && sock)
369
- svcLayer.markUp(ctl, 'observed');
370
- if (ctl)
371
- svcLayer.scaleUp(ctl);
372
- if (!sock) {
373
- const errs = ctl?.lastErrors?.length ? ` Service errors: ${ctl.lastErrors.join('; ')}` : '';
374
- (0, edge_1.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.`);
375
- return;
376
- }
377
- const podName = path.basename(sock, '.sock');
378
- req.__lensmcpPod = podName;
379
- traceStep(trace, 'pod-select', { pod: podName, pods: route.pool.socks.length, strategy: 'round-robin' });
380
- // EVICT a dead/wedged pod: drop its socket from the pool + reset the round-robin index + unlink the
381
- // ghost socket file, so rescans stay truthful and the next request re-scans / scale-from-zero
382
- // respawns. Shared by the connection-error cb AND the wedge timer below (same proven recovery).
383
- let settled = false;
384
- const evictPod = (reason) => {
385
- route.pool.socks = route.pool.socks.filter((s) => s !== sock); // drop dead pod until rescan
386
- route.pool.idx = -1; // removing an element shifts indices — reset so the next pick walks cleanly from 0
387
- try {
388
- fs.unlinkSync(sock);
389
- }
390
- catch {
391
- /* already gone */
392
- }
393
- console.error(`[gateway] pod ${podName} of ${route.project}: ${reason}`);
394
- };
395
- // WEDGE GUARD (self-heal the "request pending 2+ minutes" hang): a pod that ACCEPTS the socket but
396
- // never sends response headers — a stale socket left by an HMR hot-swap — is NOT a connection error,
397
- // so http-proxy's error cb never fires and the request would pin until `proxyTimeout` (120s). Fire on
398
- // POD_RESPONSE_TIMEOUT_MS instead: evict the pod (→ respawn on the next request) and 502 fast. Only
399
- // acts BEFORE the first response byte, so a legit long request (streaming after headers) is untouched.
400
- const wedgeTimer = setTimeout(() => {
401
- if (settled || res.headersSent || res.writableEnded)
402
- return;
403
- settled = true;
404
- evictPod(`no response within ${types_1.POD_RESPONSE_TIMEOUT_MS}ms (wedged socket) — evicted + respawning`);
405
- (0, edge_1.sendError)(res, req, 502, 'unavailable', `Pod ${podName} of ${route.project} was unresponsive — evicted, retry.`);
406
- }, types_1.POD_RESPONSE_TIMEOUT_MS);
407
- wedgeTimer.unref?.(); // never keep the gateway process alive for this timer
408
- res.on('close', () => clearTimeout(wedgeTimer));
409
- // NO autoRewrite for a socketPath target: it's an OBJECT, and http-proxy's setRedirectHostRewrite
410
- // does url.parse(options.target) on ANY 3xx response — which throws ("url must be a string, received
411
- // Object") and CRASHES the gateway the instant a pod returns a redirect (e.g. the auth IdP's
412
- // /authorize → login). A socket target has no host to rewrite anyway, and pods emit ABSOLUTE
413
- // external redirect URLs, so no rewrite is needed.
414
- proxy.web(req, res, { target: { socketPath: sock }, agent: httpAgent }, (err) => {
415
- clearTimeout(wedgeTimer);
416
- if (settled)
417
- return; // the wedge timer already evicted + responded
418
- settled = true;
419
- evictPod(err.message); // a refused sock is a GHOST file — the evict removes it so rescans stay truthful
420
- (0, edge_1.sendError)(res, req, 502, 'unavailable', `Pod ${podName} of ${route.project} unavailable — retry.`);
421
- });
422
- return;
423
- }
424
- // autoRewrite ONLY for a string (external URL) target — a non-string target crashes
425
- // setRedirectHostRewrite's url.parse(options.target) on a 3xx (see the socketPath case above).
426
- // SELF-HEAL (a TCP app upstream, e.g. a Vite dev server): on a config change Vite RESTARTS its
427
- // internal server — the process stays alive, so the port refuses for ~1-3s. Rather than an instant
428
- // 502 ("Upstream login unavailable"), retry the CONNECTION error with a short backoff until the
429
- // restart/warmup finishes (the heal window), then finally 502. Retried only BEFORE any byte is
430
- // written (the err cb fires pre-proxyRes) + only for a connection error (a real app 5xx streams
431
- // through untouched).
432
- const tcpTarget = route.target;
433
- const tcpOpts = { target: tcpTarget, autoRewrite: typeof tcpTarget === 'string', changeOrigin: true, agent: agentForTarget(tcpTarget) };
434
- const tcpDeadline = Date.now() + types_1.UPSTREAM_HEAL_WINDOW_MS;
435
- const tryTcp = () => {
436
- proxy.web(req, res, tcpOpts, (err) => {
437
- // `!res.destroyed` — see the h2 twin above: with the client gone there is nothing to heal for.
438
- if (isConnError(err) && Date.now() < tcpDeadline && !res.headersSent && !res.writableEnded && !res.destroyed) {
439
- const t = setTimeout(tryTcp, types_1.UPSTREAM_HEAL_STEP_MS);
440
- t.unref?.();
441
- return;
442
- }
443
- if (res.destroyed)
444
- return; // the client left — no stream to write an error into
445
- console.error(`[gateway] upstream ${route.project} (${String(tcpTarget)}):`, err.message);
446
- (0, edge_1.sendError)(res, req, 502, 'unavailable', `Upstream ${route.project} unavailable.`);
447
- });
448
- };
449
- tryTcp();
450
- };
451
- const destroyAgents = () => { httpAgent.destroy(); httpsAgent.destroy(); };
452
- const closeProxy = () => { try {
453
- proxy.close?.();
454
- }
455
- catch { /* ignore */ } };
456
- return { proxy, forward, agentForTarget, destroyAgents, closeProxy };
457
- }
1
+ "use strict";var q=Object.defineProperty;var b=(E,u)=>q(E,"name",{value:u,configurable:!0});var C=Object.defineProperty,s=b((E,u)=>C(E,"name",{value:u,configurable:!0}),"s");Object.defineProperty(exports,"__esModule",{value:!0}),exports.createProxy=createProxy;const tslib_1=require("tslib"),fs=tslib_1.__importStar(require("node:fs")),http=tslib_1.__importStar(require("node:http")),path=tslib_1.__importStar(require("node:path")),discovery_1=require("./discovery"),edge_1=require("./edge"),types_1=require("./types");function isConnError(E){const u=E.code??"";return u==="ECONNREFUSED"||u==="ECONNRESET"||u==="ETIMEDOUT"||u==="EPIPE"||u==="EHOSTUNREACH"||u==="ENOENT"||/ECONNREFUSED|ECONNRESET|socket hang up|EPIPE/i.test(E.message)}b(isConnError,"isConnError"),s(isConnError,"isConnError");const HOP_BY_HOP=new Set(["connection","keep-alive","proxy-connection","proxy-authenticate","proxy-authorization","te","trailer","transfer-encoding","upgrade"]),httpProxy=require("http-proxy");function createProxy(E,u,S){const{recordEdge:D,finishTrace:N,traceStep:T}=u,k={keepAlive:!0,keepAliveMsecs:3e4,maxSockets:1024,maxFreeSockets:256},w=new http.Agent(k),$=new(require("node:https")).Agent(k),x=s(r=>typeof r=="string"&&r.startsWith("https:")?$:w,"agentForTarget"),P=httpProxy.createProxyServer({xfwd:!0,secure:!1,proxyTimeout:12e4,agent:w}),O=s((r,o,e)=>{const i=s(()=>{e()||r.destroyed||r.destroy()},"onEdgeClose");o.once("close",i),r.once("close",()=>o.removeListener("close",i))},"propagateEdgeCancel");P.on("proxyReq",(r,o,e)=>{O(r,e,()=>e.writableEnded)}),P.on("proxyRes",(r,o,e)=>{const i=o.__lensmcpPod;i&&(r.headers["x-lensmcp-pod"]=i),delete r.headers["x-powered-by"],r.headers.server="LensMCP",e&&O(r,e,()=>e.writableEnded)});const M=s((r,o,e,i)=>{const d={};for(const[t,p]of Object.entries(o.headers))p===void 0||t.startsWith(":")||HOP_BY_HOP.has(t)||(d[t]=p);const m=o.socket?.remoteAddress??"",f=o.headers["x-forwarded-for"];d["x-forwarded-for"]=f?`${String(f)}, ${m}`:m,d["x-forwarded-proto"]="https",d["x-forwarded-host"]||(d["x-forwarded-host"]=o.headers.host??"");let a=http,l;if(typeof r=="object")l={socketPath:r.socketPath,path:o.url,method:o.method,headers:d,agent:w};else{const t=new URL(r),p=t.protocol==="https:";a=p?require("node:https"):http,d.host=t.host,l={protocol:t.protocol,hostname:t.hostname,port:t.port||(p?443:80),path:o.url,method:o.method,headers:d,agent:x(r)}}const n=e;n.__lensmcpErrContained||(n.__lensmcpErrContained=!0,e.on("error",()=>{}));let c;const y=s(()=>{g.destroy()},"onReqError"),h=s(()=>{c&&!c.destroyed&&c.destroy()},"onEdgeClose"),_=s(()=>{o.removeListener("error",y),e.removeListener("close",h)},"detach"),g=a.request(l,t=>{if(c=t,i.onResponse(),e.headersSent||e.writableEnded||e.destroyed){t.destroy();return}const p={};for(const[v,R]of Object.entries(t.headers))R===void 0||HOP_BY_HOP.has(v)||(p[v]=R);delete p["x-powered-by"],p.server="LensMCP";const U=o.__lensmcpPod;U&&(p["x-lensmcp-pod"]=U);try{e.writeHead(t.statusCode??502,p)}catch(v){console.error("[gateway] h2 relay: bad response header, resetting stream:",v.message),t.destroy(),e.destroyed||e.destroy(v);return}t.pipe(e);const j=s(()=>{!e.writableEnded&&!e.destroyed&&e.destroy()},"abortEdge");t.once("error",j),t.once("close",j),e.on("close",h)});g.once("error",t=>i.onError(t)),g.once("close",_),o.on("error",y),o.method==="GET"||o.method==="HEAD"||o.method==="OPTIONS"?g.end():o.pipe(g)},"pipeUpstream"),A=s(async(r,o,e)=>{const i=o.__lensmcpTrace;if(r.pool){const a=r.svc;a&&(a.lastUsed=Date.now(),a.inflight+=1,e.on("close",()=>{a.inflight-=1,a.lastUsed=Date.now()}));let l=(0,discovery_1.pickSock)(r.pool,E.scanTtlMs);if(!l&&a&&(T(i,"cold-start",{project:r.project}),await S.ensureUp(a)&&(l=(0,discovery_1.pickSock)(r.pool,E.scanTtlMs))),a&&l&&S.markUp(a,"observed"),a&&S.scaleUp(a),!l){const _=a?.lastErrors?.length?` Service errors: ${a.lastErrors.join("; ")}`:"";(0,edge_1.sendError)(e,o,503,"unavailable",`Service ${r.project} is not reachable (no pods in ${r.pool.dir}).${_} The watcher recovers and the next request retries automatically.`);return}const n=path.basename(l,".sock");o.__lensmcpPod=n,T(i,"pod-select",{pod:n,pods:r.pool.socks.length,strategy:"round-robin"});let c=!1;const y=s(_=>{r.pool.socks=r.pool.socks.filter(g=>g!==l),r.pool.idx=-1;try{fs.unlinkSync(l)}catch{}console.error(`[gateway] pod ${n} of ${r.project}: ${_}`)},"evictPod"),h=setTimeout(()=>{c||e.headersSent||e.writableEnded||(c=!0,y(`no response within ${types_1.POD_RESPONSE_TIMEOUT_MS}ms (wedged socket) \u2014 evicted + respawning`),(0,edge_1.sendError)(e,o,502,"unavailable",`Pod ${n} of ${r.project} was unresponsive \u2014 evicted, retry.`))},types_1.POD_RESPONSE_TIMEOUT_MS);h.unref?.(),e.on("close",()=>clearTimeout(h)),M({socketPath:l},o,e,{onResponse:s(()=>{clearTimeout(h),c=!0},"onResponse"),onError:s(_=>{clearTimeout(h),!c&&(c=!0,y(_.message),(0,edge_1.sendError)(e,o,502,"unavailable",`Pod ${n} of ${r.project} unavailable \u2014 retry.`))},"onError")});return}const d=r.target,m=Date.now()+types_1.UPSTREAM_HEAL_WINDOW_MS,f=s(()=>{M(d,o,e,{onResponse:s(()=>{},"onResponse"),onError:s(a=>{if(isConnError(a)&&Date.now()<m&&!e.headersSent&&!e.writableEnded&&!e.destroyed){setTimeout(f,types_1.UPSTREAM_HEAL_STEP_MS).unref?.();return}if(e.headersSent||e.writableEnded||e.destroyed){e.destroyed||e.destroy();return}console.error(`[gateway] upstream ${r.project} (${String(d)}):`,a.message),(0,edge_1.sendError)(e,o,502,"unavailable",`Upstream ${r.project} unavailable.`)},"onError")})},"tryTcp");f()},"forwardNative");return{proxy:P,forward:s(async(r,o,e)=>{const i=Date.now(),d=o.__lensmcpTrace;if(e.on("close",()=>{D(r,Date.now()-i,e.statusCode??0,o.__lensmcpCaller),N(d,r.project,e.statusCode??0,{...o.__lensmcpPod?{pod:o.__lensmcpPod}:{},...o.__lensmcpCaller?{caller:o.__lensmcpCaller}:{}})}),r.prependPrefix&&!o.url?.startsWith(r.prependPrefix)&&(o.url=r.prependPrefix+(o.url??"/")),(o.httpVersionMajor??1)>=2){await A(r,o,e);return}if(r.pool){const n=r.svc;n&&(n.lastUsed=Date.now(),n.inflight+=1,e.on("close",()=>{n.inflight-=1,n.lastUsed=Date.now()}));let c=(0,discovery_1.pickSock)(r.pool,E.scanTtlMs);if(!c&&n&&(T(d,"cold-start",{project:r.project}),await S.ensureUp(n)&&(c=(0,discovery_1.pickSock)(r.pool,E.scanTtlMs))),n&&c&&S.markUp(n,"observed"),n&&S.scaleUp(n),!c){const t=n?.lastErrors?.length?` Service errors: ${n.lastErrors.join("; ")}`:"";(0,edge_1.sendError)(e,o,503,"unavailable",`Service ${r.project} is not reachable (no pods in ${r.pool.dir}).${t} The watcher recovers and the next request retries automatically.`);return}const y=path.basename(c,".sock");o.__lensmcpPod=y,T(d,"pod-select",{pod:y,pods:r.pool.socks.length,strategy:"round-robin"});let h=!1;const _=s(t=>{r.pool.socks=r.pool.socks.filter(p=>p!==c),r.pool.idx=-1;try{fs.unlinkSync(c)}catch{}console.error(`[gateway] pod ${y} of ${r.project}: ${t}`)},"evictPod"),g=setTimeout(()=>{h||e.headersSent||e.writableEnded||(h=!0,_(`no response within ${types_1.POD_RESPONSE_TIMEOUT_MS}ms (wedged socket) \u2014 evicted + respawning`),(0,edge_1.sendError)(e,o,502,"unavailable",`Pod ${y} of ${r.project} was unresponsive \u2014 evicted, retry.`))},types_1.POD_RESPONSE_TIMEOUT_MS);g.unref?.(),e.on("close",()=>clearTimeout(g)),P.web(o,e,{target:{socketPath:c},agent:w},t=>{clearTimeout(g),!h&&(h=!0,_(t.message),(0,edge_1.sendError)(e,o,502,"unavailable",`Pod ${y} of ${r.project} unavailable \u2014 retry.`))});return}const m=r.target,f={target:m,autoRewrite:typeof m=="string",changeOrigin:!0,agent:x(m)},a=Date.now()+types_1.UPSTREAM_HEAL_WINDOW_MS,l=s(()=>{P.web(o,e,f,n=>{if(isConnError(n)&&Date.now()<a&&!e.headersSent&&!e.writableEnded&&!e.destroyed){setTimeout(l,types_1.UPSTREAM_HEAL_STEP_MS).unref?.();return}e.destroyed||(console.error(`[gateway] upstream ${r.project} (${String(m)}):`,n.message),(0,edge_1.sendError)(e,o,502,"unavailable",`Upstream ${r.project} unavailable.`))})},"tryTcp");l()},"forward"),agentForTarget:x,destroyAgents:s(()=>{w.destroy(),$.destroy()},"destroyAgents"),closeProxy:s(()=>{try{P.close?.()}catch{}},"closeProxy")}}b(createProxy,"createProxy"),s(createProxy,"createProxy");
@@ -1,72 +1 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RouteRegistry = void 0;
4
- exports.rebuildRoutesInPlace = rebuildRoutesInPlace;
5
- /**
6
- * The live, multi-workspace ROUTE-FRAGMENT registry — the core of the shared daemon
7
- * (planning/multi-workspace-gateway.md P3). ONE `:443` gateway holds a fragment per registered workspace
8
- * (its routes + its pod-service controllers) and merges them into the single flat, specificity-sorted route
9
- * table the request pipeline reads. A second workspace's `gateway start` REGISTERS its fragment; a stop
10
- * UNREGISTERS it — the daemon rebuilds live, never restarts.
11
- *
12
- * For a SINGLE workspace the merged table is byte-identical to `discoverRoutes` + the dashboard route, so
13
- * this is a no-op refactor until a second workspace registers. The per-apex `default` (see `matchRoute`)
14
- * is what keeps two workspaces' catch-alls from shadowing each other.
15
- */
16
- const manifest_1 = require("../manifest");
17
- /** A live map of `wsKey → fragment`; `merged()` is the flat table the pipeline matches against. */
18
- class RouteRegistry {
19
- constructor() {
20
- this.fragments = new Map();
21
- }
22
- /** Register (or REPLACE) a workspace's fragment. Idempotent by `wsKey`. */
23
- register(fragment) {
24
- this.fragments.set(fragment.wsKey, fragment);
25
- }
26
- /** Drop a workspace's fragment; returns it (so the caller can reap its services/dashboard). */
27
- unregister(wsKey) {
28
- const f = this.fragments.get(wsKey);
29
- this.fragments.delete(wsKey);
30
- return f;
31
- }
32
- has(wsKey) {
33
- return this.fragments.has(wsKey);
34
- }
35
- get(wsKey) {
36
- return this.fragments.get(wsKey);
37
- }
38
- keys() {
39
- return [...this.fragments.keys()];
40
- }
41
- size() {
42
- return this.fragments.size;
43
- }
44
- /** APPEND routes to an already-registered workspace's fragment (e.g. its dashboard route, added after
45
- * discovery). No-op if the workspace isn't registered. */
46
- appendRoutes(wsKey, routes) {
47
- const f = this.fragments.get(wsKey);
48
- if (f)
49
- f.routes.push(...routes);
50
- }
51
- /** Every registered workspace's pod-service controllers, flattened (the daemon's full lifecycle set). */
52
- allServices() {
53
- return [...this.fragments.values()].flatMap((f) => f.services);
54
- }
55
- /** The merged, specificity-sorted route table (host+path ▸ host ▸ per-apex catch-all). A NEW array each
56
- * call — the caller rebuilds `rt.routes` in place from it so the pipeline (which reads `rt.routes` fresh
57
- * per request) picks the change up atomically. */
58
- merged() {
59
- const all = [...this.fragments.values()].flatMap((f) => f.routes);
60
- (0, manifest_1.sortBySpecificity)(all);
61
- return all;
62
- }
63
- }
64
- exports.RouteRegistry = RouteRegistry;
65
- /** Rebuild `routes` IN PLACE from the registry (mutate the SAME array the request pipeline holds, so a
66
- * live register/unregister is picked up with no restart and no torn read — JS is single-threaded, so the
67
- * splice+push is atomic w.r.t. request handling). */
68
- function rebuildRoutesInPlace(routes, registry) {
69
- const merged = registry.merged();
70
- routes.length = 0;
71
- routes.push(...merged);
72
- }
1
+ "use strict";var u=Object.defineProperty;var n=(t,e)=>u(t,"name",{value:e,configurable:!0});var i=Object.defineProperty,r=n((t,e)=>i(t,"name",{value:e,configurable:!0}),"r");Object.defineProperty(exports,"__esModule",{value:!0}),exports.RouteRegistry=void 0,exports.rebuildRoutesInPlace=rebuildRoutesInPlace;const manifest_1=require("../manifest");class RouteRegistry{static{n(this,"RouteRegistry")}static{r(this,"RouteRegistry")}constructor(){this.fragments=new Map}register(e){this.fragments.set(e.wsKey,e)}unregister(e){const s=this.fragments.get(e);return this.fragments.delete(e),s}has(e){return this.fragments.has(e)}get(e){return this.fragments.get(e)}keys(){return[...this.fragments.keys()]}size(){return this.fragments.size}appendRoutes(e,s){const a=this.fragments.get(e);a&&a.routes.push(...s)}allServices(){return[...this.fragments.values()].flatMap(e=>e.services)}merged(){const e=[...this.fragments.values()].flatMap(s=>s.routes);return(0,manifest_1.sortBySpecificity)(e),e}}exports.RouteRegistry=RouteRegistry;function rebuildRoutesInPlace(t,e){const s=e.merged();t.length=0,t.push(...s)}n(rebuildRoutesInPlace,"rebuildRoutesInPlace"),r(rebuildRoutesInPlace,"rebuildRoutesInPlace");