@lensmcp/cluster 1.16.3 → 1.16.5
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/runtime/handler.d.ts.map +1 -1
- package/executors/gateway/runtime/handler.js +9 -0
- package/executors/gateway/runtime/proxy.d.ts.map +1 -1
- package/executors/gateway/runtime/proxy.js +195 -0
- package/executors/gateway/runtime/server.d.ts.map +1 -1
- package/executors/gateway/runtime/server.js +13 -1
- package/package.json +3 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/handler.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,KAAK,IAAI,MAAM,WAAW,CAAC;AAWvC,OAAO,KAAK,EAAE,cAAc,EAAW,MAAM,SAAS,CAAC;AACvD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAC1C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAE1C,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,cAAc,CAAC;IACnB,GAAG,EAAE,aAAa,CAAC;IACnB,IAAI,EAAE,QAAQ,CAAC;IACf,KAAK,EAAE,UAAU,CAAC;IAClB,KAAK,EAAE,UAAU,CAAC;CACnB;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,CAAC,eAAe,
|
|
1
|
+
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/handler.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,KAAK,IAAI,MAAM,WAAW,CAAC;AAWvC,OAAO,KAAK,EAAE,cAAc,EAAW,MAAM,SAAS,CAAC;AACvD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAC1C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAE1C,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,cAAc,CAAC;IACnB,GAAG,EAAE,aAAa,CAAC;IACnB,IAAI,EAAE,QAAQ,CAAC;IACf,KAAK,EAAE,UAAU,CAAC;IAClB,KAAK,EAAE,UAAU,CAAC;CACnB;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,CAAC,eAAe,CAoJrE"}
|
|
@@ -49,6 +49,15 @@ function createHandler(deps) {
|
|
|
49
49
|
: 'No gateway route for this hostname.');
|
|
50
50
|
};
|
|
51
51
|
const runPipeline = async (req, res) => {
|
|
52
|
+
// 0. HTTP/2 carries the host in the `:authority` pseudo-header, NOT `host` — the
|
|
53
|
+
// http2 compat layer exposes it as `req.authority` and omits `host` from `req.headers`.
|
|
54
|
+
// Every downstream step (route match, trace, CORS) reads `req.headers.host`, so
|
|
55
|
+
// normalize it here before anything else. No-op for HTTP/1.1 (host is already set).
|
|
56
|
+
if (!req.headers.host) {
|
|
57
|
+
const authority = req.authority ?? req.headers[':authority'];
|
|
58
|
+
if (authority)
|
|
59
|
+
req.headers.host = authority;
|
|
60
|
+
}
|
|
52
61
|
// 1. STRIP client-supplied trust headers before anything else: the gateway is
|
|
53
62
|
// the ONLY party allowed to assert `x-internal-token` (came through me) and
|
|
54
63
|
// `x-api-key-id` (validated caller identity). Without this, a keyless/TCP route
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/proxy.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,OAAO,KAAK,KAAK,MAAM,MAAM,aAAa,CAAC;AAG3C,OAAO,KAAK,EAAE,cAAc,EAAW,KAAK,EAAE,MAAM,SAAS,CAAC;AAE9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/proxy.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,OAAO,KAAK,KAAK,MAAM,MAAM,aAAa,CAAC;AAG3C,OAAO,KAAK,EAAE,cAAc,EAAW,KAAK,EAAE,MAAM,SAAS,CAAC;AAE9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AA8BhD,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IACxH,EAAE,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IACnI,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,EAAE,EAAE,CAAC,QAAQ,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,KAAK,IAAI,GAAG,IAAI,CAAC;IACrG,KAAK,CAAC,IAAI,IAAI,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1F,cAAc,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC,KAAK,GAAG,OAAO,YAAY,EAAE,KAAK,CAAC;IACzE,aAAa,IAAI,IAAI,CAAC;IACtB,UAAU,IAAI,IAAI,CAAC;CACpB;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,EAAE,QAAQ,EAAE,YAAY,GAAG,UAAU,CAgTtG"}
|
|
@@ -27,6 +27,14 @@ function isConnError(err) {
|
|
|
27
27
|
code === 'ENOENT' ||
|
|
28
28
|
/ECONNREFUSED|ECONNRESET|socket hang up|EPIPE/i.test(err.message));
|
|
29
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
|
+
]);
|
|
30
38
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
31
39
|
const httpProxy = require('http-proxy');
|
|
32
40
|
function createProxy(rt, obs, svcLayer) {
|
|
@@ -58,6 +66,186 @@ function createProxy(rt, obs, svcLayer) {
|
|
|
58
66
|
delete proxyRes.headers['x-powered-by'];
|
|
59
67
|
proxyRes.headers['server'] = 'LensMCP';
|
|
60
68
|
});
|
|
69
|
+
// --- native HTTP/2 forwarder ------------------------------------------------
|
|
70
|
+
// node-http-proxy is HTTP/1.1-only: it copies connection-specific headers onto the
|
|
71
|
+
// response, which Node's http2 layer rejects. So an h2 EDGE request (a browser, once
|
|
72
|
+
// the front door speaks h2) is forwarded here with a raw http.request to the H1
|
|
73
|
+
// upstream (pod unix-socket or TCP dev-server) and the upstream response is piped back
|
|
74
|
+
// into the h2 stream. It mirrors the node-http-proxy path's branding (x-lensmcp-pod,
|
|
75
|
+
// drop x-powered-by, server:LensMCP), pod self-heal (evict + wedge timer) and TCP
|
|
76
|
+
// heal-retry — so h2 and h1 behave identically apart from the wire framing.
|
|
77
|
+
const pipeUpstream = (target, req, res, cbs) => {
|
|
78
|
+
const outHeaders = {};
|
|
79
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
80
|
+
if (v === undefined || k.startsWith(':') || HOP_BY_HOP.has(k))
|
|
81
|
+
continue; // strip pseudo + hop-by-hop
|
|
82
|
+
outHeaders[k] = v;
|
|
83
|
+
}
|
|
84
|
+
// x-forwarded-* parity with node-http-proxy's xfwd:true.
|
|
85
|
+
const remote = req.socket?.remoteAddress ?? '';
|
|
86
|
+
const priorXff = req.headers['x-forwarded-for'];
|
|
87
|
+
outHeaders['x-forwarded-for'] = priorXff ? `${String(priorXff)}, ${remote}` : remote;
|
|
88
|
+
outHeaders['x-forwarded-proto'] = 'https';
|
|
89
|
+
if (!outHeaders['x-forwarded-host'])
|
|
90
|
+
outHeaders['x-forwarded-host'] = req.headers.host ?? '';
|
|
91
|
+
let mod = http;
|
|
92
|
+
let opts;
|
|
93
|
+
if (typeof target === 'object') {
|
|
94
|
+
// pod (unix socket): no changeOrigin — the pod ignores Host (matches the h1 path).
|
|
95
|
+
opts = { socketPath: target.socketPath, path: req.url, method: req.method, headers: outHeaders, agent: httpAgent };
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
const u = new URL(target);
|
|
99
|
+
const isHttps = u.protocol === 'https:';
|
|
100
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
101
|
+
mod = isHttps ? require('node:https') : http;
|
|
102
|
+
outHeaders['host'] = u.host; // changeOrigin:true parity — a Vite dev server matches on Host
|
|
103
|
+
opts = {
|
|
104
|
+
protocol: u.protocol,
|
|
105
|
+
hostname: u.hostname,
|
|
106
|
+
port: u.port || (isHttps ? 443 : 80),
|
|
107
|
+
path: req.url,
|
|
108
|
+
method: req.method,
|
|
109
|
+
headers: outHeaders,
|
|
110
|
+
agent: agentForTarget(target),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const upstreamReq = mod.request(opts, (upstreamRes) => {
|
|
114
|
+
cbs.onResponse();
|
|
115
|
+
if (res.headersSent || res.writableEnded || res.destroyed) {
|
|
116
|
+
upstreamRes.destroy();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const h = {};
|
|
120
|
+
for (const [k, v] of Object.entries(upstreamRes.headers)) {
|
|
121
|
+
if (v === undefined || HOP_BY_HOP.has(k))
|
|
122
|
+
continue; // hop-by-hop headers are h2-illegal
|
|
123
|
+
h[k] = v;
|
|
124
|
+
}
|
|
125
|
+
delete h['x-powered-by']; // drop the upstream framework leak (parity with proxyRes)
|
|
126
|
+
h['server'] = 'LensMCP'; // brand the hop
|
|
127
|
+
const pod = req.__lensmcpPod;
|
|
128
|
+
if (pod)
|
|
129
|
+
h['x-lensmcp-pod'] = pod;
|
|
130
|
+
try {
|
|
131
|
+
res.writeHead(upstreamRes.statusCode ?? 502, h);
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
// A header the upstream sent is illegal on an h2 stream (Node throws e.g.
|
|
135
|
+
// ERR_HTTP2_INVALID_HEADER_VALUE). RESET only THIS stream — left unhandled the throw escapes
|
|
136
|
+
// the request callback and can tear down the whole h2 SESSION, failing EVERY sibling stream on
|
|
137
|
+
// the connection at once with ERR_HTTP2_PROTOCOL_ERROR (the "burst of files failed" symptom).
|
|
138
|
+
console.error('[gateway] h2 relay: bad response header, resetting stream:', e.message);
|
|
139
|
+
upstreamRes.destroy();
|
|
140
|
+
if (!res.destroyed)
|
|
141
|
+
res.destroy(e);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
upstreamRes.pipe(res);
|
|
145
|
+
// The upstream dies MID-BODY (a pod rolling-swap or a Vite restart resets the pooled socket): RST
|
|
146
|
+
// this h2 stream cleanly instead of leaving it half-written — a dangling h2 stream is what Chrome
|
|
147
|
+
// reports as ERR_HTTP2_PROTOCOL_ERROR. A premature close surfaces as 'error' OR just 'close'
|
|
148
|
+
// ('aborted') depending on the Node version, so handle BOTH; the `writableEnded` guard makes the
|
|
149
|
+
// normal-completion 'close' a no-op. (An unhandled 'error' on upstreamRes would also throw.)
|
|
150
|
+
const abortEdge = () => { if (!res.writableEnded && !res.destroyed)
|
|
151
|
+
res.destroy(); };
|
|
152
|
+
upstreamRes.on('error', abortEdge);
|
|
153
|
+
upstreamRes.on('close', abortEdge);
|
|
154
|
+
// The client canceled: stop reading the upstream so its pooled keep-alive socket isn't left flowing
|
|
155
|
+
// (a half-drained socket is corrupt on the next reuse → would break a later request).
|
|
156
|
+
res.on('close', () => { if (!upstreamRes.destroyed)
|
|
157
|
+
upstreamRes.destroy(); });
|
|
158
|
+
});
|
|
159
|
+
upstreamReq.on('error', (err) => cbs.onError(err));
|
|
160
|
+
req.on('error', () => upstreamReq.destroy());
|
|
161
|
+
// GET/HEAD/OPTIONS carry no body — end immediately; else stream the request body upstream.
|
|
162
|
+
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS')
|
|
163
|
+
upstreamReq.end();
|
|
164
|
+
else
|
|
165
|
+
req.pipe(upstreamReq);
|
|
166
|
+
};
|
|
167
|
+
// The h2 twin of the pool/TCP branches in `forward` below — same pick / cold-start /
|
|
168
|
+
// evict / wedge / heal-retry, driving `pipeUpstream` instead of node-http-proxy.
|
|
169
|
+
const forwardNative = async (route, req, res) => {
|
|
170
|
+
const trace = req.__lensmcpTrace;
|
|
171
|
+
if (route.pool) {
|
|
172
|
+
const ctl = route.svc;
|
|
173
|
+
if (ctl) {
|
|
174
|
+
ctl.lastUsed = Date.now();
|
|
175
|
+
ctl.inflight += 1;
|
|
176
|
+
res.on('close', () => { ctl.inflight -= 1; ctl.lastUsed = Date.now(); });
|
|
177
|
+
}
|
|
178
|
+
let sock = (0, discovery_1.pickSock)(route.pool, rt.scanTtlMs);
|
|
179
|
+
if (!sock && ctl) {
|
|
180
|
+
traceStep(trace, 'cold-start', { project: route.project });
|
|
181
|
+
if (await svcLayer.ensureUp(ctl))
|
|
182
|
+
sock = (0, discovery_1.pickSock)(route.pool, rt.scanTtlMs);
|
|
183
|
+
}
|
|
184
|
+
if (ctl)
|
|
185
|
+
svcLayer.scaleUp(ctl);
|
|
186
|
+
if (!sock) {
|
|
187
|
+
const errs = ctl?.lastErrors?.length ? ` Service errors: ${ctl.lastErrors.join('; ')}` : '';
|
|
188
|
+
(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.`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const podName = path.basename(sock, '.sock');
|
|
192
|
+
req.__lensmcpPod = podName;
|
|
193
|
+
traceStep(trace, 'pod-select', { pod: podName, pods: route.pool.socks.length, strategy: 'round-robin' });
|
|
194
|
+
let settled = false;
|
|
195
|
+
const evictPod = (reason) => {
|
|
196
|
+
route.pool.socks = route.pool.socks.filter((s) => s !== sock);
|
|
197
|
+
route.pool.idx = -1;
|
|
198
|
+
try {
|
|
199
|
+
fs.unlinkSync(sock);
|
|
200
|
+
}
|
|
201
|
+
catch { /* already gone */ }
|
|
202
|
+
console.error(`[gateway] pod ${podName} of ${route.project}: ${reason}`);
|
|
203
|
+
};
|
|
204
|
+
const wedgeTimer = setTimeout(() => {
|
|
205
|
+
if (settled || res.headersSent || res.writableEnded)
|
|
206
|
+
return;
|
|
207
|
+
settled = true;
|
|
208
|
+
evictPod(`no response within ${types_1.POD_RESPONSE_TIMEOUT_MS}ms (wedged socket) — evicted + respawning`);
|
|
209
|
+
(0, edge_1.sendError)(res, req, 502, 'unavailable', `Pod ${podName} of ${route.project} was unresponsive — evicted, retry.`);
|
|
210
|
+
}, types_1.POD_RESPONSE_TIMEOUT_MS);
|
|
211
|
+
wedgeTimer.unref?.();
|
|
212
|
+
res.on('close', () => clearTimeout(wedgeTimer));
|
|
213
|
+
pipeUpstream({ socketPath: sock }, req, res, {
|
|
214
|
+
onResponse: () => { clearTimeout(wedgeTimer); settled = true; },
|
|
215
|
+
onError: (err) => {
|
|
216
|
+
clearTimeout(wedgeTimer);
|
|
217
|
+
if (settled)
|
|
218
|
+
return;
|
|
219
|
+
settled = true;
|
|
220
|
+
evictPod(err.message);
|
|
221
|
+
(0, edge_1.sendError)(res, req, 502, 'unavailable', `Pod ${podName} of ${route.project} unavailable — retry.`);
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const tcpTarget = route.target;
|
|
227
|
+
const tcpDeadline = Date.now() + types_1.UPSTREAM_HEAL_WINDOW_MS;
|
|
228
|
+
const tryTcp = () => {
|
|
229
|
+
pipeUpstream(tcpTarget, req, res, {
|
|
230
|
+
onResponse: () => { },
|
|
231
|
+
onError: (err) => {
|
|
232
|
+
if (isConnError(err) && Date.now() < tcpDeadline && !res.headersSent && !res.writableEnded) {
|
|
233
|
+
setTimeout(tryTcp, types_1.UPSTREAM_HEAL_STEP_MS);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
// Past the response headers there's nothing to send an error body into — reset the stream.
|
|
237
|
+
if (res.headersSent || res.writableEnded || res.destroyed) {
|
|
238
|
+
if (!res.destroyed)
|
|
239
|
+
res.destroy();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
console.error(`[gateway] upstream ${route.project} (${String(tcpTarget)}):`, err.message);
|
|
243
|
+
(0, edge_1.sendError)(res, req, 502, 'unavailable', `Upstream ${route.project} unavailable.`);
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
tryTcp();
|
|
248
|
+
};
|
|
61
249
|
const forward = async (route, req, res) => {
|
|
62
250
|
const startedAt = Date.now();
|
|
63
251
|
const trace = req.__lensmcpTrace;
|
|
@@ -71,6 +259,13 @@ function createProxy(rt, obs, svcLayer) {
|
|
|
71
259
|
if (route.prependPrefix && !req.url?.startsWith(route.prependPrefix)) {
|
|
72
260
|
req.url = route.prependPrefix + (req.url ?? '/');
|
|
73
261
|
}
|
|
262
|
+
// HTTP/2 edge requests (browsers, once the front door speaks h2) can't ride node-http-proxy
|
|
263
|
+
// — forward them natively. H1 requests (curl, tools, the HMR WebSocket's sibling fetches)
|
|
264
|
+
// keep the proven path below unchanged.
|
|
265
|
+
if ((req.httpVersionMajor ?? 1) >= 2) {
|
|
266
|
+
await forwardNative(route, req, res);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
74
269
|
if (route.pool) {
|
|
75
270
|
const ctl = route.svc;
|
|
76
271
|
if (ctl) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/server.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAkB,qBAAqB,EAAe,MAAM,SAAS,CAAC;AAEjH,wBAAsB,YAAY,CAChC,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/server.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAkB,qBAAqB,EAAe,MAAM,SAAS,CAAC;AAEjH,wBAAsB,YAAY,CAChC,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CAkKxB"}
|
|
@@ -120,9 +120,21 @@ async function startGateway(options, context) {
|
|
|
120
120
|
const servers = [];
|
|
121
121
|
const boundPorts = [];
|
|
122
122
|
for (const port of ports) {
|
|
123
|
+
// HTTP/2 (with allowHTTP1) for the TLS front door. A Vite dev page fetches
|
|
124
|
+
// HUNDREDS of ESM modules; over HTTP/1.1 the browser's ~6-connection-per-origin
|
|
125
|
+
// cap serializes that waterfall — each module sits "Stalled" waiting for a
|
|
126
|
+
// connection slot (measured: ~94% of a module's wall-time is stall, ~6% is the
|
|
127
|
+
// server), which is what makes dev asset loading feel slow through the gateway.
|
|
128
|
+
// h2 multiplexes every module over ONE connection + ONE TLS handshake, erasing
|
|
129
|
+
// both the 6-connection stall and the repeated handshakes. `allowHTTP1: true`
|
|
130
|
+
// keeps H1 clients working — notably Vite's HMR WebSocket, which browsers open
|
|
131
|
+
// as an H1 `Upgrade` on a SEPARATE connection (h2 has no Upgrade) and which still
|
|
132
|
+
// lands on `server.on('upgrade')` below. The forwarder (proxy.ts) branches by
|
|
133
|
+
// protocol: H1 keeps the proven node-http-proxy path; h2 uses a native forwarder
|
|
134
|
+
// because node-http-proxy emits connection-specific headers that are illegal in h2.
|
|
123
135
|
const server = pem
|
|
124
136
|
? // eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
125
|
-
require('node:
|
|
137
|
+
require('node:http2').createSecureServer({ key: pem, cert: pem, allowHTTP1: true }, (req, res) => handler(req, res))
|
|
126
138
|
: http.createServer(handler);
|
|
127
139
|
server.on('upgrade', upgrade);
|
|
128
140
|
server.on('error', (err) => console.error(`[gateway] port ${port}: ${err.code}`));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lensmcp/cluster",
|
|
3
|
-
"version": "1.16.
|
|
3
|
+
"version": "1.16.5",
|
|
4
4
|
"description": "Run your Nx workspace as a local production cluster: pods on unix sockets with true HMR, one https gateway with per-project domains, scale-from-zero, autoscale, idle-kill, local-CA TLS — observed by the LensMCP lens.",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"types": "./index.d.ts",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
-
"@lensmcp/node-instrumentation": "1.16.
|
|
69
|
-
"@lensmcp/nx-plugin": "1.16.
|
|
68
|
+
"@lensmcp/node-instrumentation": "1.16.5",
|
|
69
|
+
"@lensmcp/nx-plugin": "1.16.5",
|
|
70
70
|
"fork-ts-checker-webpack-plugin": "^9.0.0",
|
|
71
71
|
"glob": "^11.0.0",
|
|
72
72
|
"http-proxy": "^1.18.0",
|