@lensmcp/cluster 1.16.31 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/executors/gateway/runtime/lens-children.d.ts +27 -5
- package/executors/gateway/runtime/lens-children.d.ts.map +1 -1
- package/executors/gateway/runtime/lens-children.js +226 -29
- package/executors/gateway/runtime/lifecycle.d.ts +81 -2
- package/executors/gateway/runtime/lifecycle.d.ts.map +1 -1
- package/executors/gateway/runtime/lifecycle.js +201 -13
- package/executors/gateway/runtime/proxy.d.ts +4 -1
- package/executors/gateway/runtime/proxy.d.ts.map +1 -1
- package/executors/gateway/runtime/proxy.js +91 -14
- package/executors/gateway/runtime/scope.d.ts +12 -0
- package/executors/gateway/runtime/scope.d.ts.map +1 -1
- package/executors/gateway/runtime/scope.js +25 -0
- package/executors/gateway/runtime/server.d.ts +33 -0
- package/executors/gateway/runtime/server.d.ts.map +1 -1
- package/executors/gateway/runtime/server.js +77 -3
- package/executors/gateway/runtime/types.d.ts +65 -0
- package/executors/gateway/runtime/types.d.ts.map +1 -1
- package/executors/gateway/runtime/types.js +56 -1
- package/package.json +3 -3
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.sourceSetSignature = sourceSetSignature;
|
|
4
|
+
exports.sourceScopeDirs = sourceScopeDirs;
|
|
4
5
|
exports.podRecycleReason = podRecycleReason;
|
|
5
6
|
exports.lensFrontendStale = lensFrontendStale;
|
|
7
|
+
exports.lensChildWedged = lensChildWedged;
|
|
8
|
+
exports.isPortConflictExit = isPortConflictExit;
|
|
6
9
|
exports.createServiceLayer = createServiceLayer;
|
|
7
10
|
const tslib_1 = require("tslib");
|
|
8
11
|
/**
|
|
@@ -16,6 +19,7 @@ const http = tslib_1.__importStar(require("node:http"));
|
|
|
16
19
|
const path = tslib_1.__importStar(require("node:path"));
|
|
17
20
|
const node_util_1 = require("node:util");
|
|
18
21
|
const discovery_1 = require("./discovery");
|
|
22
|
+
const scope_1 = require("./scope");
|
|
19
23
|
const pod_env_1 = require("./pod-env");
|
|
20
24
|
const types_1 = require("./types");
|
|
21
25
|
/**
|
|
@@ -90,14 +94,19 @@ async function gitIgnoredPaths(root) {
|
|
|
90
94
|
/** Bounded, async, non-blocking walk of the workspace source tree → a signature of the file SET
|
|
91
95
|
* (`<count>:<xor-of-path-hashes>`). Skips node_modules/dist/build/dotdirs + `*-devserver` sock dirs + every
|
|
92
96
|
* GITIGNORED path (runtime output is not source — see `SRC_EXCLUDE`), and only counts source-extension
|
|
93
|
-
* files. Order-independent (readdir order irrelevant) and null-safe.
|
|
94
|
-
|
|
97
|
+
* files. Order-independent (readdir order irrelevant) and null-safe.
|
|
98
|
+
*
|
|
99
|
+
* `scopeDirs` (absolute, from {@link sourceScopeDirs}) narrows the walk to the subtrees a given child could
|
|
100
|
+
* actually resolve, instead of the whole monorepo — see that function for WHY. Omitted ⇒ the whole root
|
|
101
|
+
* (the original workspace-wide behavior, still used when a child's project root is unknown). Root-level
|
|
102
|
+
* loose files are always counted, so a `package.json`/`tsconfig.base.json` add reaches every scope. */
|
|
103
|
+
async function sourceSetSignature(root, scopeDirs, extraExclude) {
|
|
95
104
|
let count = 0;
|
|
96
105
|
let xor = 0;
|
|
97
106
|
const ignored = await gitIgnoredPaths(root);
|
|
98
107
|
// git speaks posix-relative; `path.relative` yields `\` on win32 — normalize before every lookup.
|
|
99
108
|
const rel = (abs) => path.relative(root, abs).split(path.sep).join('/');
|
|
100
|
-
const walk = async (dir) => {
|
|
109
|
+
const walk = async (dir, recurse = true) => {
|
|
101
110
|
if (count > SCAN_FILE_CAP)
|
|
102
111
|
return;
|
|
103
112
|
let entries;
|
|
@@ -113,7 +122,9 @@ async function sourceSetSignature(root) {
|
|
|
113
122
|
const name = e.name;
|
|
114
123
|
const abs = path.join(dir, name);
|
|
115
124
|
if (e.isDirectory()) {
|
|
116
|
-
if (
|
|
125
|
+
if (!recurse)
|
|
126
|
+
continue; // scoped mode: root's own dirs are entered only via scopeDirs
|
|
127
|
+
if (name.startsWith('.') || SRC_EXCLUDE.has(name) || extraExclude?.has(name) || name.endsWith('-devserver'))
|
|
117
128
|
continue;
|
|
118
129
|
if (ignored.has(`${rel(abs)}/`))
|
|
119
130
|
continue; // a gitignored output dir (foodguard's `artifacts/`)
|
|
@@ -130,9 +141,56 @@ async function sourceSetSignature(root) {
|
|
|
130
141
|
}
|
|
131
142
|
}
|
|
132
143
|
};
|
|
133
|
-
|
|
144
|
+
if (scopeDirs && scopeDirs.length > 0) {
|
|
145
|
+
await walk(root, false); // root-level loose files only — its subdirs come from scopeDirs
|
|
146
|
+
// Sequential, not Promise.all: `count`/`xor` are shared accumulators, and the xor is
|
|
147
|
+
// order-independent anyway — so concurrency would buy nothing but interleaving risk.
|
|
148
|
+
for (const dir of scopeDirs)
|
|
149
|
+
await walk(dir);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
await walk(root);
|
|
153
|
+
}
|
|
134
154
|
return `${count}:${(xor >>> 0).toString(36)}`;
|
|
135
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* PURE: which subtrees a child of `projectRoot` must watch for a "file set changed" verdict.
|
|
158
|
+
*
|
|
159
|
+
* The staleness signal exists to catch a vite/pod whose fs-watcher MISSED a file add. But the signature was
|
|
160
|
+
* workspace-WIDE while its consumers are PER-CHILD, so adding a backend-only `server/apps/auth/**\/*.ts`
|
|
161
|
+
* — a file in no vite module graph — recycled every frontend vite too. Worked failure (foodguard,
|
|
162
|
+
* 2026-07-28): one session writing `server/apps/auth/src/modules/totp/**` added a `.ts` every ~15-20s and
|
|
163
|
+
* held `app.foodguard.local` in near-continuous 502s, 951 recycles in a session, none of them relevant to
|
|
164
|
+
* the dashboard.
|
|
165
|
+
*
|
|
166
|
+
* The scope is the child's OWN top-level bucket plus every bucket that hosts no OTHER gateway-managed
|
|
167
|
+
* project (`managedBuckets`). In a scope-first monorepo (`web/ server/ shared/ ai/ tools/`) that yields
|
|
168
|
+
* `web + shared + ai + tools` for a frontend and `server + shared + ai + tools` for a service: a
|
|
169
|
+
* `server/**` add no longer touches a frontend, while a `shared/contracts` add still reaches both — which
|
|
170
|
+
* is correct, since both import it. It deliberately OVER-includes (a frontend watches `ai/` it may never
|
|
171
|
+
* import) because a false recycle costs a cold start whereas a MISSED one costs a wedged dev server.
|
|
172
|
+
*
|
|
173
|
+
* Both args are absolute; the result is absolute and deduped. An empty result (the project root IS the
|
|
174
|
+
* workspace root, or it sits outside it) means "no narrowing" → the caller falls back to the whole root.
|
|
175
|
+
*/
|
|
176
|
+
function sourceScopeDirs(workspaceRoot, projectRoot, managedBuckets, topLevelDirs, extraExclude) {
|
|
177
|
+
const bucketOf = (abs) => {
|
|
178
|
+
const r = path.relative(workspaceRoot, abs).split(path.sep).filter(Boolean);
|
|
179
|
+
return r.length === 0 || r[0].startsWith('..') ? '' : r[0];
|
|
180
|
+
};
|
|
181
|
+
const own = bucketOf(projectRoot);
|
|
182
|
+
if (!own)
|
|
183
|
+
return []; // project root === workspace root (or outside it) → cannot narrow
|
|
184
|
+
const managed = new Set(managedBuckets.filter(Boolean));
|
|
185
|
+
const keep = new Set([own]);
|
|
186
|
+
for (const d of topLevelDirs) {
|
|
187
|
+
if (d.startsWith('.') || SRC_EXCLUDE.has(d) || extraExclude?.has(d) || d.endsWith('-devserver'))
|
|
188
|
+
continue;
|
|
189
|
+
if (!managed.has(d))
|
|
190
|
+
keep.add(d); // a bucket owned by no managed project = shared/leaf → always watch
|
|
191
|
+
}
|
|
192
|
+
return [...keep].sort().map((d) => path.join(workspaceRoot, d));
|
|
193
|
+
}
|
|
136
194
|
/**
|
|
137
195
|
* PURE decision: should this pod be recycled as a stale-view zombie, and why? A pod qualifies only when it
|
|
138
196
|
* is a live, UP, gateway-spawned pod that is currently QUIET (0 in-flight AND idle for `settleMs`, so a busy
|
|
@@ -174,6 +232,56 @@ function lensFrontendStale(spawnedAt, lastRecycleAt, sourceSetChangedAt, now, cf
|
|
|
174
232
|
return false; // let an add/remove burst settle → one recycle
|
|
175
233
|
return true;
|
|
176
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* PURE decision: is this ALIVE lens-frontend child WEDGED — process running, port NOT listening?
|
|
237
|
+
*
|
|
238
|
+
* The third self-heal blind spot. `lensFrontendStale` catches a vite serving a stale FS view; the exit
|
|
239
|
+
* handler catches a vite that DIED. Neither sees a vite that is alive, has printed `VITE ready`, and has
|
|
240
|
+
* NO LISTEN socket — its event loop held open by the lens WS server / bridge sockets while its HTTP
|
|
241
|
+
* listener is gone. Nothing exits, so nothing auto-heals, and the app 502s until a human kills the pid
|
|
242
|
+
* (foodguard 2026-07-29 — see {@link LENS_PROBE_ENABLED}).
|
|
243
|
+
*
|
|
244
|
+
* The caller owns the socket I/O (a cheap TCP connect per tick) and accumulates `probeFails`; this owns
|
|
245
|
+
* WHEN that evidence justifies a kill. Three guards, each earning its place:
|
|
246
|
+
* - **grace** — a still-booting vite has no listener YET; killing it would be the outage, not the fix.
|
|
247
|
+
* - **N consecutive failures** (≥2, enforced here so even a misconfigured `failureThreshold: 1` can't
|
|
248
|
+
* single-blip recycle) — a momentary refusal is not a wedge.
|
|
249
|
+
* - **cooldown** — the same per-project window the two recycle paths share, so probe + staleness can
|
|
250
|
+
* never thrash one project between them.
|
|
251
|
+
* Returns the recycle reason or `null`. Kept pure (no process, no socket) so all four are unit-tested.
|
|
252
|
+
*/
|
|
253
|
+
function lensChildWedged(child, lastRecycleAt, now, cfg) {
|
|
254
|
+
if (now - child.spawnedAt < cfg.graceMs)
|
|
255
|
+
return null; // still booting — it has no listener YET
|
|
256
|
+
if (now - lastRecycleAt < cfg.cooldownMs)
|
|
257
|
+
return null; // anti-thrash (shared with the staleness recycle)
|
|
258
|
+
if (child.probeFails < Math.max(2, cfg.failureThreshold))
|
|
259
|
+
return null; // one blip is never a wedge
|
|
260
|
+
return 'wedged-no-listener';
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* PURE decision: was this child's exit a lost BIND RACE (EADDRINUSE) rather than a crash?
|
|
264
|
+
*
|
|
265
|
+
* The auto-heal respawned on a fixed backoff while the child it replaced still held the pinned port, so
|
|
266
|
+
* vite's `--strictPort` exited code=1 on bind — and that self-inflicted exit consumed a heal attempt,
|
|
267
|
+
* pushing the backoff up and lengthening the very 502 it was healing (observed: `listen EADDRINUSE
|
|
268
|
+
* 127.0.0.1:50059` → `exited (code=1 sig=null); auto-healing in 3s (attempt 2)`). A bind race is an
|
|
269
|
+
* ENVIRONMENT condition, not a code fault, so it must not spend the crash-loop budget.
|
|
270
|
+
*
|
|
271
|
+
* The signature, from evidence we already have (stdio is `inherit`, so the child's stderr is unreadable):
|
|
272
|
+
* a non-zero exit, NOT from a signal (we killed it), FAST enough that it cannot have served traffic, while
|
|
273
|
+
* some OTHER process still holds the port. If the port is free by now the next spawn will simply succeed,
|
|
274
|
+
* so it is treated as a normal heal — the conservative direction.
|
|
275
|
+
*/
|
|
276
|
+
function isPortConflictExit(exit, cfg) {
|
|
277
|
+
if (exit.signal)
|
|
278
|
+
return false; // we (or the OS) killed it — a recycle/reap, never a bind failure
|
|
279
|
+
if (exit.code === null || exit.code === 0)
|
|
280
|
+
return false; // a clean exit is not a start failure
|
|
281
|
+
if (exit.ranMs > cfg.fastFailMs)
|
|
282
|
+
return false; // it RAN ⇒ it held the port ⇒ cannot be a bind race
|
|
283
|
+
return exit.portHeld; // someone else still holds it ⇒ this exit lost the bind race
|
|
284
|
+
}
|
|
177
285
|
function createServiceLayer(rt, obs) {
|
|
178
286
|
const { emit } = obs;
|
|
179
287
|
// Route a service's lifecycle events (starting/up/down/cold-start/…) to ITS OWNING workspace's bus —
|
|
@@ -535,23 +643,100 @@ function createServiceLayer(rt, obs) {
|
|
|
535
643
|
settleMs: types_1.POD_RECYCLE_SETTLE_MS, cooldownMs: types_1.POD_STALE_RECYCLE_COOLDOWN_MS,
|
|
536
644
|
};
|
|
537
645
|
let prevSig;
|
|
646
|
+
const prevScopedSig = new Map(); // bucket → its last signature
|
|
538
647
|
let scanInFlight = false;
|
|
539
648
|
let lastScanAt = 0;
|
|
540
|
-
|
|
541
|
-
|
|
649
|
+
/** The distinct top-level buckets owned by a gateway-managed project (services + `lens:true` apps) — every
|
|
650
|
+
* OTHER bucket is shared/leaf and belongs in every scope. Recomputed per scan so a workspace registered
|
|
651
|
+
* after boot is picked up. */
|
|
652
|
+
const managedBuckets = () => {
|
|
653
|
+
const roots = rt.projectRoots ?? {};
|
|
654
|
+
const names = new Set(rt.services.map((s) => s.project));
|
|
655
|
+
for (const r of rt.routes)
|
|
656
|
+
if (r.lens)
|
|
657
|
+
names.add(r.lens.project);
|
|
658
|
+
const buckets = new Set();
|
|
659
|
+
for (const n of names) {
|
|
660
|
+
const pr = roots[n];
|
|
661
|
+
if (!pr)
|
|
662
|
+
continue;
|
|
663
|
+
const seg = path.relative(rt.root, pr).split(path.sep).filter(Boolean)[0];
|
|
664
|
+
if (seg && !seg.startsWith('..'))
|
|
665
|
+
buckets.add(seg);
|
|
666
|
+
}
|
|
667
|
+
return [...buckets];
|
|
668
|
+
};
|
|
669
|
+
/** project → the absolute dirs its child must watch, or `undefined` when it cannot be narrowed (unknown
|
|
670
|
+
* project root / no `projectsConfigurations`) → that child keeps the workspace-wide clock. */
|
|
671
|
+
const scopeFor = (project, topLevel, managed, extraExclude) => {
|
|
672
|
+
const pr = rt.projectRoots?.[project];
|
|
673
|
+
if (!pr)
|
|
674
|
+
return undefined;
|
|
675
|
+
const dirs = sourceScopeDirs(rt.root, pr, managed, topLevel, extraExclude);
|
|
676
|
+
return dirs.length > 0 ? dirs : undefined;
|
|
677
|
+
};
|
|
678
|
+
// Kick a bounded, async source-set scan when due; on a SET change (file added/removed) stamp the clocks so
|
|
679
|
+
// children that predate it recycle. Non-blocking — the sweeper never awaits it. Two tiers: the
|
|
680
|
+
// workspace-WIDE signature (the fallback + back-compat clock) and one SCOPED signature per managed
|
|
681
|
+
// project's bucket, so a `server/**` add no longer recycles a `web/**` vite (see `sourceScopeDirs`). One
|
|
682
|
+
// scan per bucket, not per project — sibling projects in a bucket share a scope, so `web/apps/dashboard`
|
|
683
|
+
// and `web/apps/authentication` cost a single walk between them.
|
|
542
684
|
const maybeScanSourceSet = () => {
|
|
543
685
|
if (scanInFlight || Date.now() - lastScanAt < types_1.POD_STALE_SCAN_MS)
|
|
544
686
|
return;
|
|
545
687
|
scanInFlight = true;
|
|
546
688
|
lastScanAt = Date.now();
|
|
547
|
-
void
|
|
548
|
-
.
|
|
549
|
-
|
|
689
|
+
void (async () => {
|
|
690
|
+
// Per-workspace `.lensmcp/config.json` sourceSet.exclude — re-read per scan so an
|
|
691
|
+
// edit takes effect without a gateway restart (one tiny fs read per scan tick).
|
|
692
|
+
const extraExclude = (0, scope_1.readSourceSetExclude)(rt.root);
|
|
693
|
+
const wide = await sourceSetSignature(rt.root, undefined, extraExclude);
|
|
694
|
+
if (prevSig !== undefined && wide !== prevSig) {
|
|
550
695
|
rt.sourceSetChangedAt = Date.now();
|
|
551
696
|
emit('info', 'source file set changed — stale pods recycle when idle', 'cluster-gateway', { kind: 'source-set-changed' });
|
|
552
697
|
}
|
|
553
|
-
prevSig =
|
|
554
|
-
|
|
698
|
+
prevSig = wide;
|
|
699
|
+
// Scoped tier. Skipped entirely without project roots (nothing to narrow by) — consumers then read
|
|
700
|
+
// the wide clock exactly as before.
|
|
701
|
+
if (!rt.projectRoots)
|
|
702
|
+
return;
|
|
703
|
+
let topLevel;
|
|
704
|
+
try {
|
|
705
|
+
topLevel = (await fs.promises.readdir(rt.root, { withFileTypes: true }))
|
|
706
|
+
.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const managed = managedBuckets();
|
|
712
|
+
const projects = new Set(rt.services.map((s) => s.project));
|
|
713
|
+
for (const r of rt.routes)
|
|
714
|
+
if (r.lens)
|
|
715
|
+
projects.add(r.lens.project);
|
|
716
|
+
// bucket → the scope dirs + the projects sharing it (one walk per bucket).
|
|
717
|
+
const byBucket = new Map();
|
|
718
|
+
for (const project of projects) {
|
|
719
|
+
const dirs = scopeFor(project, topLevel, managed, extraExclude);
|
|
720
|
+
if (!dirs)
|
|
721
|
+
continue;
|
|
722
|
+
const key = dirs.join('|');
|
|
723
|
+
const entry = byBucket.get(key) ?? { dirs, projects: [] };
|
|
724
|
+
entry.projects.push(project);
|
|
725
|
+
byBucket.set(key, entry);
|
|
726
|
+
}
|
|
727
|
+
const now = Date.now();
|
|
728
|
+
for (const [key, { dirs, projects: sharing }] of byBucket) {
|
|
729
|
+
const sig = await sourceSetSignature(rt.root, dirs, extraExclude);
|
|
730
|
+
const prev = prevScopedSig.get(key);
|
|
731
|
+
prevScopedSig.set(key, sig);
|
|
732
|
+
if (prev === undefined || sig === prev)
|
|
733
|
+
continue;
|
|
734
|
+
rt.sourceSetChangedFor ??= new Map();
|
|
735
|
+
for (const p of sharing)
|
|
736
|
+
rt.sourceSetChangedFor.set(p, now);
|
|
737
|
+
emit('info', `source file set changed in ${sharing.join(', ')}'s scope`, 'cluster-gateway', { kind: 'source-set-changed', scoped: true, projects: sharing });
|
|
738
|
+
}
|
|
739
|
+
})()
|
|
555
740
|
.catch(() => undefined)
|
|
556
741
|
.finally(() => { scanInFlight = false; });
|
|
557
742
|
};
|
|
@@ -595,7 +780,10 @@ function createServiceLayer(rt, obs) {
|
|
|
595
780
|
// stale — neither exit-respawn nor wedge-recycle sees it. Idle-gated in podRecycleReason so a busy
|
|
596
781
|
// pod is never interrupted.
|
|
597
782
|
if (live && !coldStarting && types_1.POD_RECYCLE_ENABLED) {
|
|
598
|
-
|
|
783
|
+
// Prefer this project's SCOPED clock (only the subtrees it can resolve); fall back to the
|
|
784
|
+
// workspace-wide one when the scan hasn't produced a scoped verdict for it.
|
|
785
|
+
const changedAt = rt.sourceSetChangedFor?.get(svc.project) ?? rt.sourceSetChangedAt ?? 0;
|
|
786
|
+
const reason = podRecycleReason(svc, Date.now(), changedAt, recycleCfg);
|
|
599
787
|
if (reason)
|
|
600
788
|
recyclePod(svc, reason);
|
|
601
789
|
}
|
|
@@ -6,7 +6,10 @@ import type { ServiceLayer } from './lifecycle';
|
|
|
6
6
|
export interface ProxyServer {
|
|
7
7
|
web(req: http.IncomingMessage, res: http.ServerResponse, opts: Record<string, unknown>, cb: (err: Error) => void): void;
|
|
8
8
|
ws(req: http.IncomingMessage, socket: stream.Duplex, head: Buffer, opts: Record<string, unknown>, cb: (err?: Error) => void): void;
|
|
9
|
-
on(event: 'proxyRes', cb: (proxyRes: http.IncomingMessage, req: http.IncomingMessage) => void): void;
|
|
9
|
+
on(event: 'proxyRes', cb: (proxyRes: http.IncomingMessage, req: http.IncomingMessage, res: http.ServerResponse) => void): void;
|
|
10
|
+
/** Emitted once the upstream ClientRequest has its socket — the only handle http-proxy gives us on the
|
|
11
|
+
* OUTBOUND request, and so the only place the edge-cancel propagation below can be wired. */
|
|
12
|
+
on(event: 'proxyReq', cb: (proxyReq: http.ClientRequest, req: http.IncomingMessage, res: http.ServerResponse) => void): void;
|
|
10
13
|
close?(): void;
|
|
11
14
|
}
|
|
12
15
|
export interface ProxyLayer {
|
|
@@ -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;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;
|
|
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,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,KAAK,IAAI,GAAG,IAAI,CAAC;IAC/H;kGAC8F;IAC9F,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,EAAE,EAAE,CAAC,QAAQ,EAAE,IAAI,CAAC,aAAa,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,KAAK,IAAI,GAAG,IAAI,CAAC;IAC7H,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,CAuYtG"}
|
|
@@ -56,8 +56,46 @@ function createProxy(rt, obs, svcLayer) {
|
|
|
56
56
|
// and leave the gateway-request trace open; the timeout fires the proxy
|
|
57
57
|
// error path → 502 → res 'close' → inflight decrements + trace finishes.
|
|
58
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
|
+
});
|
|
59
97
|
// Surface WHICH pod served the request (round-robin made visible).
|
|
60
|
-
proxy.on('proxyRes', (proxyRes, req) => {
|
|
98
|
+
proxy.on('proxyRes', (proxyRes, req, res) => {
|
|
61
99
|
const pod = req.__lensmcpPod;
|
|
62
100
|
if (pod)
|
|
63
101
|
proxyRes.headers['x-lensmcp-pod'] = pod;
|
|
@@ -65,6 +103,10 @@ function createProxy(rt, obs, svcLayer) {
|
|
|
65
103
|
// (e.g. NestJS/Express `x-powered-by`) and brand the hop as the LensMCP gateway.
|
|
66
104
|
delete proxyRes.headers['x-powered-by'];
|
|
67
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);
|
|
68
110
|
});
|
|
69
111
|
// --- native HTTP/2 forwarder ------------------------------------------------
|
|
70
112
|
// node-http-proxy is HTTP/1.1-only: it copies connection-specific headers onto the
|
|
@@ -114,8 +156,34 @@ function createProxy(rt, obs, svcLayer) {
|
|
|
114
156
|
// `res.destroy(err)` below) emits 'error'; UNHANDLED, that throws and can
|
|
115
157
|
// escalate to tear the whole h2 session down — the very "burst" symptom.
|
|
116
158
|
// A no-op listener keeps the fault local to THIS stream. (Harmless on h1.)
|
|
117
|
-
|
|
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
|
+
};
|
|
118
185
|
const upstreamReq = mod.request(opts, (upstreamRes) => {
|
|
186
|
+
upstream = upstreamRes;
|
|
119
187
|
cbs.onResponse();
|
|
120
188
|
if (res.headersSent || res.writableEnded || res.destroyed) {
|
|
121
189
|
upstreamRes.destroy();
|
|
@@ -154,15 +222,17 @@ function createProxy(rt, obs, svcLayer) {
|
|
|
154
222
|
// normal-completion 'close' a no-op. (An unhandled 'error' on upstreamRes would also throw.)
|
|
155
223
|
const abortEdge = () => { if (!res.writableEnded && !res.destroyed)
|
|
156
224
|
res.destroy(); };
|
|
157
|
-
upstreamRes.
|
|
158
|
-
upstreamRes.
|
|
159
|
-
|
|
160
|
-
// (a half-drained socket is corrupt on the next reuse → would break a later request).
|
|
161
|
-
res.on('close', () => { if (!upstreamRes.destroyed)
|
|
162
|
-
upstreamRes.destroy(); });
|
|
225
|
+
upstreamRes.once('error', abortEdge);
|
|
226
|
+
upstreamRes.once('close', abortEdge);
|
|
227
|
+
res.on('close', onEdgeClose);
|
|
163
228
|
});
|
|
164
|
-
upstreamReq.
|
|
165
|
-
|
|
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);
|
|
166
236
|
// GET/HEAD/OPTIONS carry no body — end immediately; else stream the request body upstream.
|
|
167
237
|
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS')
|
|
168
238
|
upstreamReq.end();
|
|
@@ -234,8 +304,11 @@ function createProxy(rt, obs, svcLayer) {
|
|
|
234
304
|
pipeUpstream(tcpTarget, req, res, {
|
|
235
305
|
onResponse: () => { },
|
|
236
306
|
onError: (err) => {
|
|
237
|
-
|
|
238
|
-
|
|
307
|
+
// `!res.destroyed`: once the CLIENT is gone there is nobody to heal for — retrying would hammer a
|
|
308
|
+
// recovering upstream for the rest of the window and then write an error into a dead stream.
|
|
309
|
+
if (isConnError(err) && Date.now() < tcpDeadline && !res.headersSent && !res.writableEnded && !res.destroyed) {
|
|
310
|
+
const t = setTimeout(tryTcp, types_1.UPSTREAM_HEAL_STEP_MS);
|
|
311
|
+
t.unref?.(); // a pending heal retry must never keep the gateway process alive
|
|
239
312
|
return;
|
|
240
313
|
}
|
|
241
314
|
// Past the response headers there's nothing to send an error body into — reset the stream.
|
|
@@ -352,10 +425,14 @@ function createProxy(rt, obs, svcLayer) {
|
|
|
352
425
|
const tcpDeadline = Date.now() + types_1.UPSTREAM_HEAL_WINDOW_MS;
|
|
353
426
|
const tryTcp = () => {
|
|
354
427
|
proxy.web(req, res, tcpOpts, (err) => {
|
|
355
|
-
|
|
356
|
-
|
|
428
|
+
// `!res.destroyed` — see the h2 twin above: with the client gone there is nothing to heal for.
|
|
429
|
+
if (isConnError(err) && Date.now() < tcpDeadline && !res.headersSent && !res.writableEnded && !res.destroyed) {
|
|
430
|
+
const t = setTimeout(tryTcp, types_1.UPSTREAM_HEAL_STEP_MS);
|
|
431
|
+
t.unref?.();
|
|
357
432
|
return;
|
|
358
433
|
}
|
|
434
|
+
if (res.destroyed)
|
|
435
|
+
return; // the client left — no stream to write an error into
|
|
359
436
|
console.error(`[gateway] upstream ${route.project} (${String(tcpTarget)}):`, err.message);
|
|
360
437
|
(0, edge_1.sendError)(res, req, 502, 'unavailable', `Upstream ${route.project} unavailable.`);
|
|
361
438
|
});
|
|
@@ -11,6 +11,18 @@ export declare function readLensScope(root: string): {
|
|
|
11
11
|
dashboardPort: number;
|
|
12
12
|
basePath: string;
|
|
13
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* Per-workspace extra SOURCE-SET excludes (`.lensmcp/config.json` →
|
|
16
|
+
* `sourceSet.exclude: string[]`) — top-level dir names the staleness scanner
|
|
17
|
+
* must NOT treat as source. The scanner deliberately over-includes every
|
|
18
|
+
* unmanaged bucket (`shared/`, `docs/`, …) in every pod's scope, but a bucket
|
|
19
|
+
* like `docs/` can host source-extension files (`.json`, `.mdx`) that no pod
|
|
20
|
+
* can ever import — each add there flips the signature and recycles pods for
|
|
21
|
+
* nothing (foodguard 2026-07-29: a `docs/**\/*-intake.json` add recycled the
|
|
22
|
+
* dashboard; 475 recycles in one day were scope-C adds). Names only (no
|
|
23
|
+
* globs), matched exactly like the built-in excludes. Empty when unset.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readSourceSetExclude(root: string): Set<string>;
|
|
14
26
|
export declare function lensKeyFrom(root: string): string;
|
|
15
27
|
export declare function lensSlug(input: string): string;
|
|
16
28
|
//# sourceMappingURL=scope.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scope.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/scope.ts"],"names":[],"mappings":"AAOA;;;8CAG8C;AAC9C,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAUpD;AAED;;yEAEyE;AACzE,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAmBpG;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQhD;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQ9C"}
|
|
1
|
+
{"version":3,"file":"scope.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/scope.ts"],"names":[],"mappings":"AAOA;;;8CAG8C;AAC9C,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAUpD;AAED;;yEAEyE;AACzE,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAmBpG;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAa9D;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQhD;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQ9C"}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.baseDomainOf = baseDomainOf;
|
|
4
4
|
exports.readLensScope = readLensScope;
|
|
5
|
+
exports.readSourceSetExclude = readSourceSetExclude;
|
|
5
6
|
exports.lensKeyFrom = lensKeyFrom;
|
|
6
7
|
exports.lensSlug = lensSlug;
|
|
7
8
|
const tslib_1 = require("tslib");
|
|
@@ -48,6 +49,30 @@ function readLensScope(root) {
|
|
|
48
49
|
const key = lensKeyFrom(root);
|
|
49
50
|
return { key, dashboardPort: 4321, basePath: '/' + key };
|
|
50
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Per-workspace extra SOURCE-SET excludes (`.lensmcp/config.json` →
|
|
54
|
+
* `sourceSet.exclude: string[]`) — top-level dir names the staleness scanner
|
|
55
|
+
* must NOT treat as source. The scanner deliberately over-includes every
|
|
56
|
+
* unmanaged bucket (`shared/`, `docs/`, …) in every pod's scope, but a bucket
|
|
57
|
+
* like `docs/` can host source-extension files (`.json`, `.mdx`) that no pod
|
|
58
|
+
* can ever import — each add there flips the signature and recycles pods for
|
|
59
|
+
* nothing (foodguard 2026-07-29: a `docs/**\/*-intake.json` add recycled the
|
|
60
|
+
* dashboard; 475 recycles in one day were scope-C adds). Names only (no
|
|
61
|
+
* globs), matched exactly like the built-in excludes. Empty when unset.
|
|
62
|
+
*/
|
|
63
|
+
function readSourceSetExclude(root) {
|
|
64
|
+
try {
|
|
65
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(root, '.lensmcp', 'config.json'), 'utf8'));
|
|
66
|
+
const raw = cfg?.sourceSet?.exclude;
|
|
67
|
+
if (Array.isArray(raw)) {
|
|
68
|
+
return new Set(raw.filter((d) => typeof d === 'string' && d.length > 0 && !d.includes('/')));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
/* no config — nothing extra to exclude */
|
|
73
|
+
}
|
|
74
|
+
return new Set();
|
|
75
|
+
}
|
|
51
76
|
function lensKeyFrom(root) {
|
|
52
77
|
try {
|
|
53
78
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
@@ -15,5 +15,38 @@ export declare function buildEdgeVerifierPool(getRoutes: () => Route[]): EdgeVer
|
|
|
15
15
|
/** Client-abort errnos that are NORMAL front-door traffic (a closed tab, a sleeping laptop, a canceled
|
|
16
16
|
* HMR socket) — dropped quietly. Anything else on an inbound socket is logged before the drop. */
|
|
17
17
|
export declare const BENIGN_SOCKET_ERRNOS: ReadonlySet<string>;
|
|
18
|
+
/**
|
|
19
|
+
* PURE classification of Node's H1 `clientError` — what to answer, and whether it is worth reporting.
|
|
20
|
+
*
|
|
21
|
+
* The old handler answered a BARE, UNLOGGED `400 Bad Request` to every cause and threw `err` away. That is
|
|
22
|
+
* how a gateway-generated 400 became indistinguishable from an application 400: a dev sees `GET
|
|
23
|
+
* /api/…/artifacts → 400` in the browser while the service logged `200`, with nothing anywhere to say the
|
|
24
|
+
* front door invented it. (Worked failure, foodguard: spurious empty-bodied 400s on real API calls, which
|
|
25
|
+
* a dev-proxy hop forwards verbatim to the browser, so they look exactly like app bugs.) MEASURED causes on
|
|
26
|
+
* Node 24: `HPE_INVALID_METHOD`, `HPE_INVALID_HEADER_TOKEN`, `HPE_HEADER_OVERFLOW`, `HPE_INVALID_EOF_STATE`.
|
|
27
|
+
*
|
|
28
|
+
* Three corrections fall out of that list:
|
|
29
|
+
* - `HPE_HEADER_OVERFLOW` is **431**, not 400 — the request was well-formed, just too big.
|
|
30
|
+
* - `ERR_HTTP_REQUEST_TIMEOUT` is **408** (what Node's own default handler answers) — a slow client is not
|
|
31
|
+
* a malformed one, and calling it 400 sends a dev hunting a nonexistent parse bug.
|
|
32
|
+
* - `HPE_INVALID_EOF_STATE` means the peer half-closed MID-MESSAGE — the signature of a client that went
|
|
33
|
+
* away, and of the classic keep-alive REUSE RACE (a pooled client socket written to just as the server
|
|
34
|
+
* closes it, see `GATEWAY_KEEPALIVE_TIMEOUT_MS`). Writing a 400 into that socket is precisely what turns
|
|
35
|
+
* a harmless race into a client-visible 400, so it is treated like `ECONNRESET`: destroy, say nothing.
|
|
36
|
+
* Everything unrecognized keeps the previous behavior (400 + destroy), now logged.
|
|
37
|
+
*/
|
|
38
|
+
export interface ClientErrorVerdict {
|
|
39
|
+
/** Status line to best-effort write, or `null` for "write nothing, just destroy". */
|
|
40
|
+
status: 400 | 408 | 431 | null;
|
|
41
|
+
/** Log + emit? A routine client abort is not worth a line (and a scanner must not be able to flood). */
|
|
42
|
+
report: boolean;
|
|
43
|
+
/** Short cause label for the log/event. */
|
|
44
|
+
cause: string;
|
|
45
|
+
}
|
|
46
|
+
export declare function classifyClientError(code: string | undefined, message: string): ClientErrorVerdict;
|
|
47
|
+
/** Per-cause log throttle for `clientError`: a desynced client or a port scanner can produce these in a
|
|
48
|
+
* tight loop, and the point is a dev NOTICING one — not drowning in thousands. One line per cause per
|
|
49
|
+
* window; the suppressed count rides the next admitted line. Exported for the unit test. */
|
|
50
|
+
export declare function createClientErrorLogGate(windowMs?: number): (cause: string) => boolean;
|
|
18
51
|
export declare function startGateway(options: GatewayRuntimeOptions, context: GatewayContext): Promise<GatewayHandle>;
|
|
19
52
|
//# sourceMappingURL=server.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAkB,KAAK,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AAK/D,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAkB,qBAAqB,EAAe,KAAK,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAkB,KAAK,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AAK/D,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAkB,qBAAqB,EAAe,KAAK,EAAE,MAAM,SAAS,CAAC;AA2BxH;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,KAAK,EAAE,GAAG,gBAAgB,CA4ChF;AAED;mGACmG;AACnG,eAAO,MAAM,oBAAoB,EAAE,WAAW,CAAC,MAAM,CAA8E,CAAC;AAEpI;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,kBAAkB;IACjC,qFAAqF;IACrF,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,CAAC;IAC/B,wGAAwG;IACxG,MAAM,EAAE,OAAO,CAAC;IAChB,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,kBAAkB,CAQjG;AAQD;;6FAE6F;AAC7F,wBAAgB,wBAAwB,CAAC,QAAQ,SAAS,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAStF;AAkBD,wBAAsB,YAAY,CAChC,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CAsUxB"}
|