@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.
- package/basic-ssl.js +1 -241
- package/build-scope-patterns.js +1 -40
- package/create-webpack-dev.js +1 -186
- package/create-webpack-prod.js +1 -169
- package/executors/build/build.impl.js +1 -98
- package/executors/gateway/gateway-errors.js +1 -43
- package/executors/gateway/gateway.impl.js +1 -53
- package/executors/gateway/gateway.lib.js +1 -29
- package/executors/gateway/health-check.js +1 -66
- package/executors/gateway/jwks-verify.js +1 -121
- package/executors/gateway/main.prod-gateway.js +2 -573
- package/executors/gateway/main.rollout.js +11 -117
- package/executors/gateway/manifest.js +1 -374
- package/executors/gateway/metrics.js +1 -56
- package/executors/gateway/otel-tracing.js +1 -74
- package/executors/gateway/prod-gateway.lib.js +1 -22
- package/executors/gateway/prod-runtime/access-log.js +1 -24
- package/executors/gateway/prod-runtime/app.js +1 -123
- package/executors/gateway/prod-runtime/auth.js +1 -51
- package/executors/gateway/prod-runtime/cors.js +1 -40
- package/executors/gateway/prod-runtime/edge.js +1 -65
- package/executors/gateway/prod-runtime/handler.js +1 -226
- package/executors/gateway/prod-runtime/hooks.js +1 -42
- package/executors/gateway/prod-runtime/observability.js +1 -125
- package/executors/gateway/prod-runtime/rollout.js +1 -103
- package/executors/gateway/prod-runtime/routing.js +1 -40
- package/executors/gateway/prod-runtime/server.js +1 -79
- package/executors/gateway/prod-runtime/trust.js +1 -32
- package/executors/gateway/prod-runtime/types.js +1 -2
- package/executors/gateway/prod-runtime/upgrade.js +4 -116
- package/executors/gateway/prod-runtime/upstream.js +1 -21
- package/executors/gateway/providers-prod.js +1 -232
- package/executors/gateway/rate-limit.js +2 -75
- package/executors/gateway/registry-source.js +1 -131
- package/executors/gateway/rollout-ops.js +2 -167
- package/executors/gateway/runtime/auth.js +1 -64
- package/executors/gateway/runtime/chooser.js +12 -45
- package/executors/gateway/runtime/control.js +1 -128
- package/executors/gateway/runtime/dev-auth.js +1 -108
- package/executors/gateway/runtime/discovery.js +1 -123
- package/executors/gateway/runtime/edge.js +1 -47
- package/executors/gateway/runtime/handler.js +1 -183
- package/executors/gateway/runtime/hooks.js +1 -55
- package/executors/gateway/runtime/lens-children.js +1 -651
- package/executors/gateway/runtime/lifecycle.js +3 -842
- package/executors/gateway/runtime/observability.js +2 -148
- package/executors/gateway/runtime/pod-env.js +2 -89
- package/executors/gateway/runtime/proxy.js +1 -457
- package/executors/gateway/runtime/route-registry.js +1 -72
- package/executors/gateway/runtime/scope.js +1 -117
- package/executors/gateway/runtime/server.js +3 -487
- package/executors/gateway/runtime/service-keys.js +1 -49
- package/executors/gateway/runtime/types.js +1 -151
- package/executors/gateway/runtime/upgrade.js +1 -71
- package/executors/gateway/runtime/workspace-registry.js +1 -99
- package/executors/gateway/ssrf-guard.js +1 -190
- package/executors/serve/serve.impl.js +1 -280
- package/executors/trust/trust.impl.js +4 -162
- package/gateway.js +1 -35
- package/index.js +1 -16
- package/main.devserver.js +10 -1117
- package/package.json +4 -3
- package/tsgo-check-plugin.js +4 -364
- 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");
|