@lensmcp/cluster 1.18.4 → 1.18.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/basic-ssl.js +1 -241
  2. package/build-scope-patterns.js +1 -40
  3. package/create-webpack-dev.js +1 -186
  4. package/create-webpack-prod.js +1 -169
  5. package/executors/build/build.impl.js +1 -98
  6. package/executors/gateway/gateway-errors.js +1 -43
  7. package/executors/gateway/gateway.impl.js +1 -53
  8. package/executors/gateway/gateway.lib.js +1 -29
  9. package/executors/gateway/health-check.js +1 -66
  10. package/executors/gateway/jwks-verify.js +1 -121
  11. package/executors/gateway/main.prod-gateway.js +2 -573
  12. package/executors/gateway/main.rollout.js +11 -117
  13. package/executors/gateway/manifest.js +1 -374
  14. package/executors/gateway/metrics.js +1 -56
  15. package/executors/gateway/otel-tracing.js +1 -74
  16. package/executors/gateway/prod-gateway.lib.js +1 -22
  17. package/executors/gateway/prod-runtime/access-log.js +1 -24
  18. package/executors/gateway/prod-runtime/app.js +1 -123
  19. package/executors/gateway/prod-runtime/auth.js +1 -51
  20. package/executors/gateway/prod-runtime/cors.js +1 -40
  21. package/executors/gateway/prod-runtime/edge.js +1 -65
  22. package/executors/gateway/prod-runtime/handler.js +1 -226
  23. package/executors/gateway/prod-runtime/hooks.js +1 -42
  24. package/executors/gateway/prod-runtime/observability.js +1 -125
  25. package/executors/gateway/prod-runtime/rollout.js +1 -103
  26. package/executors/gateway/prod-runtime/routing.js +1 -40
  27. package/executors/gateway/prod-runtime/server.js +1 -79
  28. package/executors/gateway/prod-runtime/trust.js +1 -32
  29. package/executors/gateway/prod-runtime/types.js +1 -2
  30. package/executors/gateway/prod-runtime/upgrade.js +4 -116
  31. package/executors/gateway/prod-runtime/upstream.js +1 -21
  32. package/executors/gateway/providers-prod.js +1 -232
  33. package/executors/gateway/rate-limit.js +2 -75
  34. package/executors/gateway/registry-source.js +1 -131
  35. package/executors/gateway/rollout-ops.js +2 -167
  36. package/executors/gateway/runtime/auth.js +1 -64
  37. package/executors/gateway/runtime/chooser.js +12 -45
  38. package/executors/gateway/runtime/control.js +1 -128
  39. package/executors/gateway/runtime/dev-auth.js +1 -108
  40. package/executors/gateway/runtime/discovery.js +1 -123
  41. package/executors/gateway/runtime/edge.js +1 -47
  42. package/executors/gateway/runtime/handler.js +1 -183
  43. package/executors/gateway/runtime/hooks.js +1 -55
  44. package/executors/gateway/runtime/lens-children.js +1 -651
  45. package/executors/gateway/runtime/lifecycle.js +3 -842
  46. package/executors/gateway/runtime/observability.js +2 -148
  47. package/executors/gateway/runtime/pod-env.js +2 -89
  48. package/executors/gateway/runtime/proxy.js +1 -457
  49. package/executors/gateway/runtime/route-registry.js +1 -72
  50. package/executors/gateway/runtime/scope.js +1 -117
  51. package/executors/gateway/runtime/server.js +3 -487
  52. package/executors/gateway/runtime/service-keys.js +1 -49
  53. package/executors/gateway/runtime/types.js +1 -151
  54. package/executors/gateway/runtime/upgrade.js +1 -71
  55. package/executors/gateway/runtime/workspace-registry.js +1 -99
  56. package/executors/gateway/ssrf-guard.js +1 -190
  57. package/executors/serve/serve.impl.js +1 -280
  58. package/executors/trust/trust.impl.js +4 -162
  59. package/gateway.js +1 -35
  60. package/index.js +1 -16
  61. package/main.devserver.js +10 -1117
  62. package/package.json +4 -3
  63. package/tsgo-check-plugin.js +4 -364
  64. package/typecheck-bus.js +4 -256
@@ -1,117 +1 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.baseDomainOf = baseDomainOf;
4
- exports.readLensScope = readLensScope;
5
- exports.deriveMcpHttpPort = deriveMcpHttpPort;
6
- exports.readSourceSetExclude = readSourceSetExclude;
7
- exports.lensKeyFrom = lensKeyFrom;
8
- exports.lensSlug = lensSlug;
9
- const tslib_1 = require("tslib");
10
- /**
11
- * Per-workspace lens scope + dashboard-host derivation. Pure (fs reads only),
12
- * mirrors the `lensmcp` CLI's `workspace-scope` (kept in lock-step).
13
- */
14
- const fs = tslib_1.__importStar(require("node:fs"));
15
- const path = tslib_1.__importStar(require("node:path"));
16
- /** The base domain for the lens dashboard host — the longest common dotted
17
- * suffix of the declared cluster hosts (e.g. ['api.tetros.ai.local',
18
- * 'tetros.ai.local'] → 'tetros.ai.local'). Falls back to the first host, then
19
- * to 'lens.local' if no host is declared. */
20
- function baseDomainOf(hosts) {
21
- const clean = hosts.filter((h) => !!h && !h.startsWith('*.'));
22
- if (clean.length === 0)
23
- return 'local';
24
- // Use the SHORTEST host as the candidate base (most likely the apex), then
25
- // require every other host to end with it.
26
- const shortest = clean.reduce((a, b) => (b.length < a.length ? b : a));
27
- if (clean.every((h) => h === shortest || h.endsWith('.' + shortest)))
28
- return shortest;
29
- // No clean common apex — derive the trailing 2-3 labels of the shortest host.
30
- const parts = shortest.split('.');
31
- return parts.slice(-Math.min(parts.length, 3)).join('.');
32
- }
33
- /** Lens-infra port bases — must equal `BASE_PORTS` in the CLI's workspace-scope. */
34
- const BASE_PORTS = { dashboard: 4321, mcpHttp: 4500 };
35
- /** FNV-1a → 0..(range-1). Same derivation as the CLI's `hashToRange`. */
36
- function hashToRange(s, range) {
37
- let h = 0x811c9dc5;
38
- for (let i = 0; i < s.length; i++) {
39
- h ^= s.charCodeAt(i);
40
- h = Math.imul(h, 0x01000193);
41
- }
42
- return Math.abs(h | 0) % range;
43
- }
44
- /** Per-workspace lens scope (key + ports + mount path). Prefers the
45
- * CLI-written `.lensmcp/config.json` (so the gateway agrees with `lensmcp gateway`
46
- * / `lensmcp dashboard` / the MCP shim); falls back to deriving from the workspace. */
47
- function readLensScope(root) {
48
- try {
49
- const cfg = JSON.parse(fs.readFileSync(path.join(root, '.lensmcp', 'config.json'), 'utf8'));
50
- if (cfg && typeof cfg.key === 'string') {
51
- return {
52
- key: cfg.key,
53
- dashboardPort: cfg.ports?.dashboard ?? BASE_PORTS.dashboard,
54
- // Derive rather than fall back to a constant: a constant would put two
55
- // config-less workspaces on ONE port, which is the cross-feed bug.
56
- mcpHttpPort: cfg.ports?.mcpHttp ?? deriveMcpHttpPort(cfg.key),
57
- basePath: cfg.dashboardBasePath ?? '/' + cfg.key,
58
- };
59
- }
60
- }
61
- catch {
62
- /* no config yet — derive below */
63
- }
64
- const key = lensKeyFrom(root);
65
- return {
66
- key,
67
- dashboardPort: BASE_PORTS.dashboard,
68
- mcpHttpPort: deriveMcpHttpPort(key),
69
- basePath: '/' + key,
70
- };
71
- }
72
- /** The workspace's MCP HTTP port, derived from its key. In lock-step with the CLI. */
73
- function deriveMcpHttpPort(key) {
74
- return BASE_PORTS.mcpHttp + hashToRange(key, 200);
75
- }
76
- /**
77
- * Per-workspace extra SOURCE-SET excludes (`.lensmcp/config.json` →
78
- * `sourceSet.exclude: string[]`) — top-level dir names the staleness scanner
79
- * must NOT treat as source. The scanner deliberately over-includes every
80
- * unmanaged bucket (`shared/`, `docs/`, …) in every pod's scope, but a bucket
81
- * like `docs/` can host source-extension files (`.json`, `.mdx`) that no pod
82
- * can ever import — each add there flips the signature and recycles pods for
83
- * nothing (foodguard 2026-07-29: a `docs/**\/*-intake.json` add recycled the
84
- * dashboard; 475 recycles in one day were scope-C adds). Names only (no
85
- * globs), matched exactly like the built-in excludes. Empty when unset.
86
- */
87
- function readSourceSetExclude(root) {
88
- try {
89
- const cfg = JSON.parse(fs.readFileSync(path.join(root, '.lensmcp', 'config.json'), 'utf8'));
90
- const raw = cfg?.sourceSet?.exclude;
91
- if (Array.isArray(raw)) {
92
- return new Set(raw.filter((d) => typeof d === 'string' && d.length > 0 && !d.includes('/')));
93
- }
94
- }
95
- catch {
96
- /* no config — nothing extra to exclude */
97
- }
98
- return new Set();
99
- }
100
- function lensKeyFrom(root) {
101
- try {
102
- const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
103
- if (pkg?.name)
104
- return lensSlug(pkg.name);
105
- }
106
- catch {
107
- /* fall through to dir name */
108
- }
109
- return lensSlug(path.basename(root));
110
- }
111
- function lensSlug(input) {
112
- return (input
113
- .toLowerCase()
114
- .replace(/^@[^/]+\//, '')
115
- .replace(/[^a-z0-9]+/g, '-')
116
- .replace(/(^-|-$)/g, '') || 'workspace');
117
- }
1
+ "use strict";var p=Object.defineProperty;var n=(t,r)=>p(t,"name",{value:r,configurable:!0});var c=Object.defineProperty,s=n((t,r)=>c(t,"name",{value:r,configurable:!0}),"s");Object.defineProperty(exports,"__esModule",{value:!0}),exports.baseDomainOf=baseDomainOf,exports.readLensScope=readLensScope,exports.deriveMcpHttpPort=deriveMcpHttpPort,exports.readSourceSetExclude=readSourceSetExclude,exports.lensKeyFrom=lensKeyFrom,exports.lensSlug=lensSlug;const tslib_1=require("tslib"),fs=tslib_1.__importStar(require("node:fs")),path=tslib_1.__importStar(require("node:path"));function baseDomainOf(t){const r=t.filter(o=>!!o&&!o.startsWith("*."));if(r.length===0)return"local";const e=r.reduce((o,i)=>i.length<o.length?i:o);if(r.every(o=>o===e||o.endsWith("."+e)))return e;const a=e.split(".");return a.slice(-Math.min(a.length,3)).join(".")}n(baseDomainOf,"baseDomainOf"),s(baseDomainOf,"baseDomainOf");const BASE_PORTS={dashboard:4321,mcpHttp:4500};function hashToRange(t,r){let e=2166136261;for(let a=0;a<t.length;a++)e^=t.charCodeAt(a),e=Math.imul(e,16777619);return Math.abs(e|0)%r}n(hashToRange,"hashToRange"),s(hashToRange,"hashToRange");function readLensScope(t){try{const e=JSON.parse(fs.readFileSync(path.join(t,".lensmcp","config.json"),"utf8"));if(e&&typeof e.key=="string")return{key:e.key,dashboardPort:e.ports?.dashboard??BASE_PORTS.dashboard,mcpHttpPort:e.ports?.mcpHttp??deriveMcpHttpPort(e.key),basePath:e.dashboardBasePath??"/"+e.key}}catch{}const r=lensKeyFrom(t);return{key:r,dashboardPort:BASE_PORTS.dashboard,mcpHttpPort:deriveMcpHttpPort(r),basePath:"/"+r}}n(readLensScope,"readLensScope"),s(readLensScope,"readLensScope");function deriveMcpHttpPort(t){return BASE_PORTS.mcpHttp+hashToRange(t,200)}n(deriveMcpHttpPort,"deriveMcpHttpPort"),s(deriveMcpHttpPort,"deriveMcpHttpPort");function readSourceSetExclude(t){try{const r=JSON.parse(fs.readFileSync(path.join(t,".lensmcp","config.json"),"utf8"))?.sourceSet?.exclude;if(Array.isArray(r))return new Set(r.filter(e=>typeof e=="string"&&e.length>0&&!e.includes("/")))}catch{}return new Set}n(readSourceSetExclude,"readSourceSetExclude"),s(readSourceSetExclude,"readSourceSetExclude");function lensKeyFrom(t){try{const r=JSON.parse(fs.readFileSync(path.join(t,"package.json"),"utf8"));if(r?.name)return lensSlug(r.name)}catch{}return lensSlug(path.basename(t))}n(lensKeyFrom,"lensKeyFrom"),s(lensKeyFrom,"lensKeyFrom");function lensSlug(t){return t.toLowerCase().replace(/^@[^/]+\//,"").replace(/[^a-z0-9]+/g,"-").replace(/(^-|-$)/g,"")||"workspace"}n(lensSlug,"lensSlug"),s(lensSlug,"lensSlug");
@@ -1,487 +1,3 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.BENIGN_SOCKET_ERRNOS = void 0;
4
- exports.buildEdgeVerifierPool = buildEdgeVerifierPool;
5
- exports.classifyClientError = classifyClientError;
6
- exports.createClientErrorLogGate = createClientErrorLogGate;
7
- exports.startGateway = startGateway;
8
- const tslib_1 = require("tslib");
9
- /**
10
- * The composition root. `startGateway` resolves options, discovers routes, wires
11
- * every layer (observability → service lifecycle → lens children → proxy → auth →
12
- * hooks → handler/upgrade), boots TLS + listeners, and returns a `GatewayHandle`.
13
- *
14
- * The TOP-TO-BOTTOM ORDER here is itself a contract: service keys load before the
15
- * runtime is built; lens children boot (pushing the dashboard route) BEFORE the
16
- * cert SANs are derived from `routes`; eager services boot before listeners; the
17
- * `gateway-up` event fires only after the ports are bound.
18
- */
19
- const http = tslib_1.__importStar(require("node:http"));
20
- const path = tslib_1.__importStar(require("node:path"));
21
- const discovery_1 = require("./discovery");
22
- const scope_1 = require("./scope");
23
- const route_registry_1 = require("./route-registry");
24
- const control_1 = require("./control");
25
- const workspace_registry_1 = require("./workspace-registry");
26
- const service_keys_1 = require("./service-keys");
27
- const observability_1 = require("./observability");
28
- const lifecycle_1 = require("./lifecycle");
29
- const lens_children_1 = require("./lens-children");
30
- const proxy_1 = require("./proxy");
31
- const auth_1 = require("./auth");
32
- const hooks_1 = require("./hooks");
33
- const handler_1 = require("./handler");
34
- const upgrade_1 = require("./upgrade");
35
- const jwks_verify_1 = require("../jwks-verify");
36
- const types_1 = require("./types");
37
- /**
38
- * A `fetch`-shaped shim for the JWKS pull that accepts the dev gateway's OWN self-signed cert. The dev
39
- * IdP's JWKS is served behind the same local HTTPS front door (e.g. `https://auth.tetros.ai.local`), whose
40
- * cert chains to the local basic-ssl CA — global `fetch` would reject it. Read-only GET of a public,
41
- * gateway-served JWKS document; the SIGNATURE trust comes from verifying tokens against those keys, not
42
- * from the transport. Falls back to plain `http` for an http URL. Dev-only (only the dev executor uses it).
43
- */
44
- function insecureLocalFetch(url, init) {
45
- return new Promise((resolve, reject) => {
46
- const mod = url.startsWith('https:') ? require('node:https') : require('node:http');
47
- const req = mod.request(url, { method: 'GET', headers: init?.headers, rejectUnauthorized: false }, (res) => {
48
- const chunks = [];
49
- res.on('data', (c) => chunks.push(c));
50
- res.on('end', () => {
51
- const status = res.statusCode ?? 0;
52
- const text = Buffer.concat(chunks).toString('utf8');
53
- resolve({ ok: status >= 200 && status < 300, status, json: async () => JSON.parse(text) });
54
- });
55
- });
56
- req.on('error', reject);
57
- req.end();
58
- });
59
- }
60
- /**
61
- * The dev edge's JWT verifiers — matching the prod gateway's RS256/JWKS posture so the LOCAL edge rejects
62
- * forged tokens instead of decode-accepting them, and MULTI-ISSUER for the shared daemon: every
63
- * `auth.<apex>` host in the LIVE route table is an IdP for its apex, and `for(host)` picks the verifier
64
- * whose apex the request host belongs to (longest apex wins) — so a foodguard token is verified against
65
- * `auth.foodguard.local/jwks.json`, a tetros token against `auth.tetros.ai.local/jwks.json`, on the SAME
66
- * :443 daemon. `LENSMCP_GW_JWKS_URL` (explicit, same env as prod) keeps its old meaning: one verifier for
67
- * everything. Auth hosts come from project.json (trusted), never from a token — no SSRF/attacker-chosen
68
- * JWKS. `for()` returns undefined when the host matches no IdP apex (→ non-HS256 tokens fail closed
69
- * unless the insecure opt-in is set).
70
- */
71
- function buildEdgeVerifierPool(getRoutes) {
72
- const explicit = process.env['LENSMCP_GW_JWKS_URL'];
73
- const issuer = process.env['LENSMCP_GW_JWT_ISS'];
74
- const byApex = new Map();
75
- let explicitVerifier;
76
- const make = (jwksUrl, label) => {
77
- console.log(`[gateway] edge JWT: verifying via JWKS ${jwksUrl}${label}`);
78
- const v = (0, jwks_verify_1.createJwksVerifier)({ jwksUrl, ...(issuer ? { issuer } : {}), fetchImpl: insecureLocalFetch });
79
- void v.refresh().catch((e) => console.warn('[gateway] edge JWKS warm failed (will retry):', e.message));
80
- return v;
81
- };
82
- return {
83
- // PER-APEX resolution over the LIVE route table: every `auth.<apex>` host is an
84
- // IdP for its apex, so a SHARED multi-workspace daemon verifies each workspace's
85
- // tokens against ITS OWN IdP's JWKS. Reading the routes lazily means a workspace
86
- // REGISTERED LATER (control-plane /register) gets its verifier with no rebuild
87
- // hook — and `LENSMCP_GW_JWKS_URL` keeps its old meaning (one verifier for all).
88
- // The auth host comes from project.json (trusted), never from a token — no
89
- // attacker-chosen JWKS.
90
- for(host) {
91
- if (explicit)
92
- return (explicitVerifier ??= make(explicit, ''));
93
- const h = host.split(':')[0].toLowerCase();
94
- let best;
95
- for (const r of getRoutes()) {
96
- const rh = r.host;
97
- if (typeof rh !== 'string' || !/^auth\./.test(rh) || rh.startsWith('internal.'))
98
- continue;
99
- const apex = rh.slice('auth.'.length);
100
- if ((h === apex || h.endsWith(`.${apex}`)) && (!best || apex.length > best.length))
101
- best = apex;
102
- }
103
- if (!best)
104
- return undefined;
105
- let v = byApex.get(best);
106
- if (!v) {
107
- v = make(`https://auth.${best}/jwks.json`, ` (auto-derived for apex ${best})`);
108
- byApex.set(best, v);
109
- }
110
- return v;
111
- },
112
- stop() {
113
- explicitVerifier?.stop();
114
- for (const v of byApex.values())
115
- v.stop();
116
- },
117
- };
118
- }
119
- /** Client-abort errnos that are NORMAL front-door traffic (a closed tab, a sleeping laptop, a canceled
120
- * HMR socket) — dropped quietly. Anything else on an inbound socket is logged before the drop. */
121
- exports.BENIGN_SOCKET_ERRNOS = new Set(['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'ECONNABORTED', 'ECANCELED']);
122
- function classifyClientError(code, message) {
123
- if (code && exports.BENIGN_SOCKET_ERRNOS.has(code))
124
- return { status: null, report: false, cause: 'client-abort' };
125
- // Peer half-closed mid-message: nobody is waiting for a 400, and writing one is the spurious-400 vector.
126
- if (code === 'HPE_INVALID_EOF_STATE')
127
- return { status: null, report: false, cause: 'truncated-at-eof' };
128
- if (code === 'ERR_HTTP_REQUEST_TIMEOUT')
129
- return { status: 408, report: true, cause: 'request-timeout' };
130
- if (code === 'HPE_HEADER_OVERFLOW')
131
- return { status: 431, report: true, cause: 'header-overflow' };
132
- if (code?.startsWith('HPE_'))
133
- return { status: 400, report: true, cause: 'parse-error' };
134
- return { status: 400, report: true, cause: code ?? message ?? 'unknown' };
135
- }
136
- const CLIENT_ERROR_REASON = {
137
- 400: 'Bad Request',
138
- 408: 'Request Timeout',
139
- 431: 'Request Header Fields Too Large',
140
- };
141
- /** Per-cause log throttle for `clientError`: a desynced client or a port scanner can produce these in a
142
- * tight loop, and the point is a dev NOTICING one — not drowning in thousands. One line per cause per
143
- * window; the suppressed count rides the next admitted line. Exported for the unit test. */
144
- function createClientErrorLogGate(windowMs = 10_000) {
145
- const lastAt = new Map();
146
- return (cause) => {
147
- const now = Date.now();
148
- const prev = lastAt.get(cause) ?? 0;
149
- if (now - prev < windowMs)
150
- return false;
151
- lastAt.set(cause, now);
152
- return true;
153
- };
154
- }
155
- /** Attach the inbound-socket error guard. EVERY socket the front door accepts gets an 'error' listener
156
- * the moment it exists — both the raw TCP socket ('connection') and the post-handshake TLSSocket
157
- * ('secureConnection', a DIFFERENT emitter). A client abort is routine, but with NO listener Node turns
158
- * it into an UNHANDLED 'error' event that kills the whole daemon (observed: a days-old gateway died on
159
- * `read ECONNRESET` at TLSWrap.onStreamRead — the exposed window is a socket the http layer has handed
160
- * off, e.g. right after an 'upgrade', where no internal handler is attached any more). Handling =
161
- * destroy the ONE socket; never the process. */
162
- function guardInboundSocket(socket) {
163
- socket.on('error', (err) => {
164
- if (!exports.BENIGN_SOCKET_ERRNOS.has(err.code ?? '')) {
165
- console.warn(`[gateway] inbound socket error (${err.code ?? err.message}) — dropping this connection only`);
166
- }
167
- socket.destroy();
168
- });
169
- }
170
- async function startGateway(options, context) {
171
- // NB: this local `https` (TLS-on flag) deliberately SHADOWS the `node:https`
172
- // module — which is why TLS/agents are reached via `require('node:https')`.
173
- const https = options.https !== false;
174
- const ports = options.ports?.length ? options.ports : [443];
175
- const scanTtlMs = options.scanTtlMs ?? 1500;
176
- const startupTimeoutMs = options.startupTimeoutMs ?? 120_000;
177
- const sweepMs = options.sweepMs ?? 10_000;
178
- const trafficFlushMs = options.trafficFlushMs ?? 5000;
179
- // Set by stop(); read via rt.stopped() so an in-flight cold start neither
180
- // spawns a process nor logs after the gateway is torn down.
181
- let stopped = false;
182
- // The gateway is a first-class lens source — the shared event file the MCP tails.
183
- const eventFile = process.env['LENSMCP_EVENT_FILE'] ?? path.join(context.root, '.lensmcp', 'events.jsonl');
184
- // --- discover ---------------------------------------------------------------
185
- // The workspace key namespaces this workspace's pod sock dirs ($TMPDIR/<wsKey>/<service>-devserver) so a
186
- // SHARED multi-workspace gateway can host several workspaces without their same-named services colliding
187
- // (planning/multi-workspace-gateway.md). It flows to each service's devserver via `LENSMCP_WS_KEY` (see
188
- // lifecycle.ts), and `discoverRoutes` derives the matching pool dir the gateway scans.
189
- const wsKey = (0, scope_1.readLensScope)(context.root).key;
190
- const { routes: discovered, services } = (0, discovery_1.discoverRoutes)(context.root, context.projectsConfigurations?.projects ?? {}, undefined, wsKey);
191
- if (discovered.length === 0) {
192
- throw new Error('[gateway] no project declares a `cluster` field — nothing to route.');
193
- }
194
- // --- the live multi-workspace route-fragment registry -----------------------
195
- // This workspace registers its OWN fragment; `routes` is the merged table rebuilt IN PLACE from the
196
- // registry (byte-identical to `discovered` for one workspace). A second workspace registers its fragment
197
- // via the control endpoint and the daemon rebuilds live (planning/multi-workspace-gateway.md P3).
198
- const registry = new route_registry_1.RouteRegistry();
199
- registry.register({ wsKey, root: context.root, routes: discovered, services });
200
- const routes = []; // the LIVE table the request pipeline reads (a stable ref, mutated in place)
201
- // Assigned after the TLS servers are built (below) — re-mints the gateway cert with the CURRENT route
202
- // hosts and hot-swaps it. Declared HERE (above rebuildRoutes) so rebuildRoutes can trigger it AFTER every
203
- // live route change; a no-op until the TLS servers exist (the boot mint happens separately, below).
204
- let refreshCert = () => { };
205
- const rebuildRoutes = () => {
206
- (0, route_registry_1.rebuildRoutesInPlace)(routes, registry);
207
- // The cert tracks the live route hosts BY CONSTRUCTION: any register/unregister rebuilds the table then
208
- // re-mints, so a dynamically-registered workspace's hostnames always join the SANs (no ERR_CERT_COMMON_
209
- // NAME_INVALID). Wiring it here (not in the control onRegister) avoids the ordering trap where the cert
210
- // was minted from the STALE table because onRegister ran before the control handler's rebuild.
211
- refreshCert();
212
- };
213
- rebuildRoutes();
214
- // --- per-service api keys (the gateway is the trust boundary) ---------------
215
- const { serviceKeys, keyToProject } = (0, service_keys_1.loadServiceKeys)(context.root, services);
216
- // --- the shared runtime (state + knobs; stopped() is a live getter) ---------
217
- const rt = {
218
- root: context.root,
219
- wsKey,
220
- eventFile,
221
- routes,
222
- registry,
223
- rebuildRoutes,
224
- services,
225
- serviceKeys,
226
- keyToProject,
227
- scanTtlMs,
228
- startupTimeoutMs,
229
- sweepMs,
230
- trafficFlushMs,
231
- https,
232
- // Absolute project root per project name — the scanner narrows each child's staleness scope with it
233
- // (`sourceScopeDirs`), so a `server/**` file add stops recycling every `web/**` vite. Absent (a bare
234
- // context with no projectsConfigurations) ⇒ the workspace-wide clock, i.e. previous behavior.
235
- projectRoots: Object.fromEntries(Object.entries(context.projectsConfigurations?.projects ?? {})
236
- .map(([name, cfg]) => [name, path.resolve(context.root, cfg.root)])),
237
- stopped: () => stopped,
238
- };
239
- // --- layers -----------------------------------------------------------------
240
- const obs = (0, observability_1.createObservability)(eventFile, { trafficFlushMs });
241
- // One gate for every front-door port — a desynced client must not get N log lines because we bound N ports.
242
- const admitClientErrorLog = createClientErrorLogGate();
243
- const svcLayer = (0, lifecycle_1.createServiceLayer)(rt, obs);
244
- const lens = (0, lens_children_1.createLensChildren)(rt, obs, options);
245
- // The daemon CONTROL PLANE (loopback ~/.lensmcp/control.sock): a second workspace's `gateway start`
246
- // REGISTERS its fragment here instead of fighting for :443, and the daemon HOSTS its services (spawns
247
- // them, model A) + its dashboard (planning/multi-workspace-gateway.md P3). Harmless for a lone gateway.
248
- const workspaceReapers = new Map();
249
- let controlServer;
250
- try {
251
- controlServer = (0, control_1.startControlServer)({
252
- rt,
253
- killService: (svc, reason) => svcLayer.killSpawned(svc, reason),
254
- onRegister: async (fragment) => {
255
- // Host the registered workspace's dashboard AND its lens FRONTEND apps (both spawned in ITS root/bus;
256
- // the dashboard appends its lensmcp.local/<key> route + the apps rewrite their route targets to the
257
- // actual bound port — all BEFORE the control handler's rebuild, which AWAITS this). The cert re-mint
258
- // happens in rebuildRoutes (called by the control handler right after this), once the full table is live.
259
- const reapDash = await lens.hostWorkspaceDashboard(fragment);
260
- const reapApps = await lens.hostWorkspaceApps(fragment);
261
- workspaceReapers.set(fragment.wsKey, () => { reapDash?.(); reapApps(); });
262
- },
263
- onUnregister: (fragment) => {
264
- workspaceReapers.get(fragment.wsKey)?.();
265
- workspaceReapers.delete(fragment.wsKey);
266
- },
267
- });
268
- }
269
- catch (e) {
270
- console.warn('[gateway] control plane failed to start (multi-workspace register disabled):', e.message);
271
- }
272
- const proxyLayer = (0, proxy_1.createProxy)(rt, obs, svcLayer);
273
- // Edge JWT verification (mirrors the prod gateway): when a JWKS source is configured/derivable, the dev
274
- // edge verifies RS256 tokens by `kid` against the IdP's published keys and REJECTS anything it can't
275
- // verify (rogue kid / bad sig / alg:none / HS256-confusion / expired) — closing the dev decode-only bypass.
276
- const edgeVerifiers = buildEdgeVerifierPool(() => rt.routes);
277
- const edgeAuth = (0, auth_1.createEdgeAuth)(rt, obs, edgeVerifiers);
278
- // Warm the key cache (non-fatal, non-blocking): the IdP pod may still be booting, so a failure here just
279
- // means the first tokens fail closed until the on-miss/periodic refresh lands the keys (matches prod).
280
- // Custom user middleware (the JWT seam; internal.* routes skip it) is wired as
281
- // the BUILT-IN FIRST `afterAuth` hook — so "custom middleware runs AFTER core
282
- // auth" and the `{ mode:'middleware' }` trace step are preserved.
283
- let middleware;
284
- if (options.middleware) {
285
- // eslint-disable-next-line @typescript-eslint/no-require-imports
286
- const mod = require(path.resolve(context.root, options.middleware));
287
- middleware = (mod.default ?? mod);
288
- }
289
- const builtinMiddleware = (ctx) => {
290
- if (!middleware)
291
- return;
292
- try {
293
- middleware(ctx.req);
294
- }
295
- catch (e) {
296
- obs.traceStep(ctx.trace, 'auth', { mode: 'middleware', ok: false, reason: e.message });
297
- if (ctx.route)
298
- obs.finishTrace(ctx.trace, ctx.route.project, 401);
299
- ctx.sendError(401, 'unauthorized', `Gateway middleware rejected the request: ${e.message}`);
300
- }
301
- };
302
- const userHooks = options.hooks ?? {};
303
- const hooks = (0, hooks_1.createHooks)({
304
- ...userHooks,
305
- afterAuth: [builtinMiddleware, ...(userHooks.afterAuth ?? [])],
306
- });
307
- const handler = (0, handler_1.createHandler)({ rt, obs, auth: edgeAuth, proxy: proxyLayer, hooks });
308
- const upgrade = (0, upgrade_1.createUpgrade)({ rt, auth: edgeAuth, proxy: proxyLayer, hooks });
309
- // Background timers (both `.unref()`'d): traffic-edge flush + idle sweeper.
310
- const edgeFlusher = obs.startEdgeFlusher();
311
- const sweeper = svcLayer.startSweeper();
312
- // --- lens-mode children: dashboard + MCP singletons + `lens:true` apps ------
313
- // MUST run before TLS — it pushes the dashboard route, which joins the cert SANs.
314
- await lens.bootSingletonsAndApps();
315
- // Eager services boot with the gateway.
316
- for (const svc of services) {
317
- if (svc.decl.eager)
318
- void svcLayer.ensureUp(svc);
319
- }
320
- // --- TLS + listeners --------------------------------------------------------
321
- let pem;
322
- let caPath;
323
- let mintCert;
324
- if (https) {
325
- // eslint-disable-next-line @typescript-eslint/no-require-imports
326
- const basicSsl = require('../../../basic-ssl');
327
- const cacheDir = (0, service_keys_1.gatewayCacheDir)(context.root);
328
- // SANs = the CURRENT route hosts. `rt.routes` is rebuilt live on register, so a re-mint (refreshCert
329
- // below) automatically includes a newly-registered workspace's hostnames.
330
- mintCert = () => basicSsl.getCertificateSync(cacheDir, 'gateway', rt.routes.map((r) => r.host).filter((h) => !!h));
331
- pem = mintCert();
332
- caPath = basicSsl.caCertPath(); // machine-level (~/.lensmcp/ca) — one anchor for every daemon
333
- }
334
- const servers = [];
335
- const boundPorts = [];
336
- for (const port of ports) {
337
- // HTTP/2 (with allowHTTP1) for the TLS front door. A Vite dev page fetches
338
- // HUNDREDS of ESM modules; over HTTP/1.1 the browser's ~6-connection-per-origin
339
- // cap serializes that waterfall — each module sits "Stalled" waiting for a
340
- // connection slot (measured: ~94% of a module's wall-time is stall, ~6% is the
341
- // server), which is what makes dev asset loading feel slow through the gateway.
342
- // h2 multiplexes every module over ONE connection + ONE TLS handshake, erasing
343
- // both the 6-connection stall and the repeated handshakes. `allowHTTP1: true`
344
- // keeps H1 clients working — notably Vite's HMR WebSocket, which browsers open
345
- // as an H1 `Upgrade` on a SEPARATE connection (h2 has no Upgrade) and which still
346
- // lands on `server.on('upgrade')` below. The forwarder (proxy.ts) branches by
347
- // protocol: H1 keeps the proven node-http-proxy path; h2 uses a native forwarder
348
- // because node-http-proxy emits connection-specific headers that are illegal in h2.
349
- const server = pem
350
- ? // eslint-disable-next-line @typescript-eslint/no-require-imports
351
- require('node:http2').createSecureServer({
352
- key: pem,
353
- cert: pem,
354
- allowHTTP1: true,
355
- // A full Vite dev-app reload streams TENS of MB of transformed ESM
356
- // over ONE h2 session. Node's default `maxSessionMemory` is 10 MB —
357
- // cross it and the session RSTs its streams / sends GOAWAY, which the
358
- // browser reports as a BURST of ERR_HTTP2_PROTOCOL_ERROR (every
359
- // in-flight module fails at the same instant). Raise the ceiling and
360
- // the per-stream/peer limits to dev-front-door proportions.
361
- maxSessionMemory: 512,
362
- settings: { maxConcurrentStreams: 512 },
363
- peerMaxConcurrentStreams: 512,
364
- }, (req, res) => handler(req, res))
365
- : http.createServer(handler);
366
- server.on('upgrade', upgrade);
367
- server.on('error', (err) => console.error(`[gateway] port ${port}: ${err.code}`));
368
- // Inbound-socket resilience (the ECONNRESET-crash fix): guard every accepted socket, and end
369
- // handshake-phase ('tlsClientError') / H1 parse-phase ('clientError') faults on the ONE connection.
370
- // Registering 'clientError' replaces Node's default 400-writer, so keep its semantics: a best-effort
371
- // 400 on a still-writable socket, then destroy.
372
- server.on('connection', guardInboundSocket);
373
- server.on('secureConnection', guardInboundSocket); // never fires on a plain-http server — harmless
374
- server.on('tlsClientError', (_err, tlsSocket) => tlsSocket.destroy());
375
- server.on('clientError', (rawErr, socket) => {
376
- // Node adds `bytesParsed`/`rawPacket` on an HTTP parse error (not on the base ErrnoException type):
377
- // `bytesParsed` is the single most useful field for telling a truncated reuse race (0 / very low) from
378
- // a genuinely malformed request, which is exactly what the old handler discarded.
379
- const err = rawErr;
380
- const v = classifyClientError(err.code, err.message);
381
- // OBSERVABILITY (this used to be silent — the whole reason a gateway-invented 400 looked like an app
382
- // bug). Rate-limited per cause so a port scanner or a desynced client cannot flood the log; the bus
383
- // `emit` is separately flood-guarded by its fingerprint.
384
- if (v.report && admitClientErrorLog(v.cause)) {
385
- const peer = socket.remoteAddress ?? 'unknown';
386
- const answer = v.status === null ? 'dropped' : String(v.status);
387
- console.warn(`[gateway] inbound H1 clientError from ${peer}: ${v.cause} (${err.code ?? err.message}${err.bytesParsed !== undefined ? `, bytesParsed=${err.bytesParsed}` : ''}) → ${answer}. This status is the GATEWAY's, not the upstream service's.`);
388
- obs.emit('warning', `inbound clientError: ${v.cause} → ${answer}`, `gateway-client-error:${v.cause}`, {
389
- kind: 'gateway-client-error', cause: v.cause, code: err.code ?? null, status: v.status, peer,
390
- bytesParsed: err.bytesParsed ?? null,
391
- });
392
- }
393
- // Preserved intent: a best-effort status on a still-writable socket, then destroy.
394
- if (v.status !== null && socket.writable)
395
- socket.end(`HTTP/1.1 ${v.status} ${CLIENT_ERROR_REASON[v.status]}\r\n\r\n`);
396
- socket.destroy();
397
- });
398
- // KEEP-ALIVE REUSE RACE. Node's server default `keepAliveTimeout` is 5s — and a Node HTTP client's
399
- // pooled agent (Node 19+ `globalAgent` is keepAlive:true; vite's dev proxy uses it) idles on ~the same
400
- // 5s. Both sides then race the SAME deadline: the client writes a request onto a socket the server is
401
- // simultaneously closing, the server reads a truncated message, and the front door answers a bare
402
- // `400` that the dev proxy forwards to the browser verbatim — a 400 on a request the service would
403
- // have served 200. The documented fix is for the server's idle window to EXCEED every client's, so the
404
- // server never closes first and the client always controls socket reuse. `headersTimeout` must stay
405
- // above `keepAliveTimeout` (it bounds a request that has started arriving), and `requestTimeout` keeps
406
- // its Node default. Dev-only front door, a handful of clients → holding idle sockets longer is free.
407
- server.keepAliveTimeout = types_1.GATEWAY_KEEPALIVE_TIMEOUT_MS;
408
- if (server.headersTimeout < types_1.GATEWAY_KEEPALIVE_TIMEOUT_MS + 5_000) {
409
- server.headersTimeout = types_1.GATEWAY_KEEPALIVE_TIMEOUT_MS + 5_000;
410
- }
411
- // h2 SESSION resilience: without a `sessionError` handler ANY session-level
412
- // fault (a client GOAWAY mid-burst, a flood-protection trip, a decode error)
413
- // is unhandled → Node tears the session down, failing EVERY sibling stream at
414
- // once. Handle it so ONE bad session logs + dies alone, never throwing and
415
- // never taking the process (or other clients' sessions) with it. `session`
416
- // errors are surfaced BOTH per-session and (unhandled) as `sessionError`.
417
- const isH2 = pem != null;
418
- if (isH2) {
419
- const h2 = server;
420
- h2.on('session', (session) => {
421
- session.on('error', (err) => {
422
- const code = err.code ?? err.message;
423
- console.warn(`[gateway] h2 session error (${code}) — dropping this session only`);
424
- if (!session.destroyed)
425
- session.destroy();
426
- });
427
- });
428
- h2.on('sessionError', (err) => {
429
- console.warn(`[gateway] h2 sessionError: ${err.code ?? err.message}`);
430
- });
431
- }
432
- await new Promise((resolve) => {
433
- server.listen(port, () => {
434
- const actual = server.address()?.port ?? port;
435
- boundPorts.push(actual);
436
- console.log(`[gateway] Listening on ${pem ? 'https' : 'http'}://localhost:${actual}`);
437
- resolve();
438
- });
439
- });
440
- servers.push(server);
441
- }
442
- // Now that the TLS servers exist, wire the live cert refresh: on a workspace register/unregister the
443
- // control endpoint calls this to re-mint the gateway cert with the CURRENT route hosts and hot-swap it
444
- // into every HTTP/2 secure server (setSecureContext), so a dynamically-registered workspace's hostnames
445
- // are covered (no ERR_CERT_COMMON_NAME_INVALID).
446
- if (mintCert) {
447
- refreshCert = () => {
448
- const fresh = mintCert();
449
- for (const s of servers) {
450
- s.setSecureContext?.({ key: fresh, cert: fresh });
451
- }
452
- };
453
- }
454
- console.log('[gateway] Routes:');
455
- for (const r of routes) {
456
- const dest = r.pool ? `pods@${r.pool.dir}` : r.target;
457
- console.log(`[gateway] ${r.host ?? '(default)'} → ${dest}${r.prependPrefix ? ` (+${r.prependPrefix})` : ''}${r.internal ? ' [internal: no middleware]' : ''} [${r.project}]`);
458
- }
459
- if (caPath)
460
- console.log(`[gateway] CA: ${caPath} — one-time setup via the trust executor.`);
461
- obs.emit('info', `gateway up: ${routes.length} routes on ${boundPorts.join('/')}`, 'cluster-gateway', {
462
- kind: 'gateway-up',
463
- ports: boundPorts,
464
- routes: routes.map((r) => ({ host: r.host ?? '(default)', project: r.project, mode: r.pool ? 'pods' : 'tcp', internal: !!r.internal, prependPrefix: r.prependPrefix })),
465
- });
466
- const stop = async () => {
467
- if (stopped)
468
- return;
469
- stopped = true;
470
- clearInterval(edgeFlusher);
471
- clearInterval(sweeper);
472
- obs.emit('info', 'gateway down', 'cluster-gateway', { kind: 'gateway-down' });
473
- edgeVerifiers.stop(); // clear every JWKS periodic-refresh timer
474
- controlServer?.close(); // tear down the control plane + its socket
475
- (0, workspace_registry_1.unregisterWorkspace)(wsKey); // drop this workspace from the global chooser registry (graceful stop)
476
- for (const svc of services)
477
- svcLayer.killSpawned(svc, 'gateway shutdown');
478
- // Reap the lens-mode managed children (dashboard + MCP singletons + each `lens:true` app's Vite/capture)
479
- // — SIGTERM → grace → SIGKILL, awaited, so a SIGTERM-ignoring vite is gone BEFORE the daemon exits
480
- // (otherwise it orphans + keeps its port). Same teardown as the pod services.
481
- await lens.reapAll();
482
- await Promise.all(servers.map((s) => new Promise((r) => s.close(() => r()))));
483
- proxyLayer.closeProxy();
484
- proxyLayer.destroyAgents(); // release pooled upstream sockets
485
- };
486
- return { ports: boundPorts, routes, services, stop };
487
- }
1
+ "use strict";var Z=Object.defineProperty;var m=(r,o)=>Z(r,"name",{value:o,configurable:!0});var X=Object.defineProperty,n=m((r,o)=>X(r,"name",{value:o,configurable:!0}),"n");Object.defineProperty(exports,"__esModule",{value:!0}),exports.BENIGN_SOCKET_ERRNOS=void 0,exports.buildEdgeVerifierPool=buildEdgeVerifierPool,exports.classifyClientError=classifyClientError,exports.createClientErrorLogGate=createClientErrorLogGate,exports.startGateway=startGateway;const tslib_1=require("tslib"),http=tslib_1.__importStar(require("node:http")),path=tslib_1.__importStar(require("node:path")),discovery_1=require("./discovery"),scope_1=require("./scope"),route_registry_1=require("./route-registry"),control_1=require("./control"),workspace_registry_1=require("./workspace-registry"),service_keys_1=require("./service-keys"),observability_1=require("./observability"),lifecycle_1=require("./lifecycle"),lens_children_1=require("./lens-children"),proxy_1=require("./proxy"),auth_1=require("./auth"),hooks_1=require("./hooks"),handler_1=require("./handler"),upgrade_1=require("./upgrade"),jwks_verify_1=require("../jwks-verify"),types_1=require("./types");function insecureLocalFetch(r,o){return new Promise((g,h)=>{const w=(r.startsWith("https:")?require("node:https"):require("node:http")).request(r,{method:"GET",headers:o?.headers,rejectUnauthorized:!1},_=>{const l=[];_.on("data",u=>l.push(u)),_.on("end",()=>{const u=_.statusCode??0,a=Buffer.concat(l).toString("utf8");g({ok:u>=200&&u<300,status:u,json:n(async()=>JSON.parse(a),"json")})})});w.on("error",h),w.end()})}m(insecureLocalFetch,"insecureLocalFetch"),n(insecureLocalFetch,"insecureLocalFetch");function buildEdgeVerifierPool(r){const o=process.env.LENSMCP_GW_JWKS_URL,g=process.env.LENSMCP_GW_JWT_ISS,h=new Map;let w;const _=n((l,u)=>{console.log(`[gateway] edge JWT: verifying via JWKS ${l}${u}`);const a=(0,jwks_verify_1.createJwksVerifier)({jwksUrl:l,...g?{issuer:g}:{},fetchImpl:insecureLocalFetch});return a.refresh().catch(E=>console.warn("[gateway] edge JWKS warm failed (will retry):",E.message)),a},"make");return{for(l){if(o)return w??=_(o,"");const u=l.split(":")[0].toLowerCase();let a;for(const v of r()){const S=v.host;if(typeof S!="string"||!/^auth\./.test(S)||S.startsWith("internal."))continue;const d=S.slice(5);(u===d||u.endsWith(`.${d}`))&&(!a||d.length>a.length)&&(a=d)}if(!a)return;let E=h.get(a);return E||(E=_(`https://auth.${a}/jwks.json`,` (auto-derived for apex ${a})`),h.set(a,E)),E},stop(){w?.stop();for(const l of h.values())l.stop()}}}m(buildEdgeVerifierPool,"buildEdgeVerifierPool"),n(buildEdgeVerifierPool,"buildEdgeVerifierPool"),exports.BENIGN_SOCKET_ERRNOS=new Set(["ECONNRESET","EPIPE","ETIMEDOUT","ECONNABORTED","ECANCELED"]);function classifyClientError(r,o){return r&&exports.BENIGN_SOCKET_ERRNOS.has(r)?{status:null,report:!1,cause:"client-abort"}:r==="HPE_INVALID_EOF_STATE"?{status:null,report:!1,cause:"truncated-at-eof"}:r==="ERR_HTTP_REQUEST_TIMEOUT"?{status:408,report:!0,cause:"request-timeout"}:r==="HPE_HEADER_OVERFLOW"?{status:431,report:!0,cause:"header-overflow"}:r?.startsWith("HPE_")?{status:400,report:!0,cause:"parse-error"}:{status:400,report:!0,cause:r??o??"unknown"}}m(classifyClientError,"classifyClientError"),n(classifyClientError,"classifyClientError");const CLIENT_ERROR_REASON={400:"Bad Request",408:"Request Timeout",431:"Request Header Fields Too Large"};function createClientErrorLogGate(r=1e4){const o=new Map;return g=>{const h=Date.now(),w=o.get(g)??0;return h-w<r?!1:(o.set(g,h),!0)}}m(createClientErrorLogGate,"createClientErrorLogGate"),n(createClientErrorLogGate,"createClientErrorLogGate");function guardInboundSocket(r){r.on("error",o=>{exports.BENIGN_SOCKET_ERRNOS.has(o.code??"")||console.warn(`[gateway] inbound socket error (${o.code??o.message}) \u2014 dropping this connection only`),r.destroy()})}m(guardInboundSocket,"guardInboundSocket"),n(guardInboundSocket,"guardInboundSocket");async function startGateway(r,o){const g=r.https!==!1,h=r.ports?.length?r.ports:[443],w=r.scanTtlMs??1500,_=r.startupTimeoutMs??12e4,l=r.sweepMs??1e4,u=r.trafficFlushMs??5e3;let a=!1;const E=process.env.LENSMCP_EVENT_FILE??path.join(o.root,".lensmcp","events.jsonl"),v=(0,scope_1.readLensScope)(o.root).key,{routes:S,services:d}=(0,discovery_1.discoverRoutes)(o.root,o.projectsConfigurations?.projects??{},void 0,v);if(S.length===0)throw new Error("[gateway] no project declares a `cluster` field \u2014 nothing to route.");const q=new route_registry_1.RouteRegistry;q.register({wsKey:v,root:o.root,routes:S,services:d});const T=[];let x=n(()=>{},"refreshCert");const N=n(()=>{(0,route_registry_1.rebuildRoutesInPlace)(T,q),x()},"rebuildRoutes");N();const{serviceKeys:V,keyToProject:D}=(0,service_keys_1.loadServiceKeys)(o.root,d),f={root:o.root,wsKey:v,eventFile:E,routes:T,registry:q,rebuildRoutes:N,services:d,serviceKeys:V,keyToProject:D,scanTtlMs:w,startupTimeoutMs:_,sweepMs:l,trafficFlushMs:u,https:g,projectRoots:Object.fromEntries(Object.entries(o.projectsConfigurations?.projects??{}).map(([e,t])=>[e,path.resolve(o.root,t.root)])),stopped:n(()=>a,"stopped")},p=(0,observability_1.createObservability)(E,{trafficFlushMs:u}),B=createClientErrorLogGate(),P=(0,lifecycle_1.createServiceLayer)(f,p),b=(0,lens_children_1.createLensChildren)(f,p,r),j=new Map;let I;try{I=(0,control_1.startControlServer)({rt:f,killService:n((e,t)=>P.killSpawned(e,t),"killService"),onRegister:n(async e=>{const t=await b.hostWorkspaceDashboard(e),i=await b.hostWorkspaceApps(e);j.set(e.wsKey,()=>{t?.(),i()})},"onRegister"),onUnregister:n(e=>{j.get(e.wsKey)?.(),j.delete(e.wsKey)},"onUnregister")})}catch(e){console.warn("[gateway] control plane failed to start (multi-workspace register disabled):",e.message)}const C=(0,proxy_1.createProxy)(f,p,P),L=buildEdgeVerifierPool(()=>f.routes),W=(0,auth_1.createEdgeAuth)(f,p,L);let A;if(r.middleware){const e=require(path.resolve(o.root,r.middleware));A=e.default??e}const J=n(e=>{if(A)try{A(e.req)}catch(t){p.traceStep(e.trace,"auth",{mode:"middleware",ok:!1,reason:t.message}),e.route&&p.finishTrace(e.trace,e.route.project,401),e.sendError(401,"unauthorized",`Gateway middleware rejected the request: ${t.message}`)}},"builtinMiddleware"),G=r.hooks??{},K=(0,hooks_1.createHooks)({...G,afterAuth:[J,...G.afterAuth??[]]}),U=(0,handler_1.createHandler)({rt:f,obs:p,auth:W,proxy:C,hooks:K}),Y=(0,upgrade_1.createUpgrade)({rt:f,auth:W,proxy:C,hooks:K}),z=p.startEdgeFlusher(),Q=P.startSweeper();await b.bootSingletonsAndApps();for(const e of d)e.decl.eager&&P.ensureUp(e);let k,M,$;if(g){const e=require("../../../basic-ssl"),t=(0,service_keys_1.gatewayCacheDir)(o.root);$=n(()=>e.getCertificateSync(t,"gateway",f.routes.map(i=>i.host).filter(i=>!!i)),"mintCert"),k=$(),M=e.caCertPath()}const O=[],R=[];for(const e of h){const t=k?require("node:http2").createSecureServer({key:k,cert:k,allowHTTP1:!0,maxSessionMemory:512,settings:{maxConcurrentStreams:512},peerMaxConcurrentStreams:512},(i,s)=>U(i,s)):http.createServer(U);if(t.on("upgrade",Y),t.on("error",i=>console.error(`[gateway] port ${e}: ${i.code}`)),t.on("connection",guardInboundSocket),t.on("secureConnection",guardInboundSocket),t.on("tlsClientError",(i,s)=>s.destroy()),t.on("clientError",(i,s)=>{const y=i,c=classifyClientError(y.code,y.message);if(c.report&&B(c.cause)){const F=s.remoteAddress??"unknown",H=c.status===null?"dropped":String(c.status);console.warn(`[gateway] inbound H1 clientError from ${F}: ${c.cause} (${y.code??y.message}${y.bytesParsed!==void 0?`, bytesParsed=${y.bytesParsed}`:""}) \u2192 ${H}. This status is the GATEWAY's, not the upstream service's.`),p.emit("warning",`inbound clientError: ${c.cause} \u2192 ${H}`,`gateway-client-error:${c.cause}`,{kind:"gateway-client-error",cause:c.cause,code:y.code??null,status:c.status,peer:F,bytesParsed:y.bytesParsed??null})}c.status!==null&&s.writable&&s.end(`HTTP/1.1 ${c.status} ${CLIENT_ERROR_REASON[c.status]}\r
2
+ \r
3
+ `),s.destroy()}),t.keepAliveTimeout=types_1.GATEWAY_KEEPALIVE_TIMEOUT_MS,t.headersTimeout<types_1.GATEWAY_KEEPALIVE_TIMEOUT_MS+5e3&&(t.headersTimeout=types_1.GATEWAY_KEEPALIVE_TIMEOUT_MS+5e3),k!=null){const i=t;i.on("session",s=>{s.on("error",y=>{const c=y.code??y.message;console.warn(`[gateway] h2 session error (${c}) \u2014 dropping this session only`),s.destroyed||s.destroy()})}),i.on("sessionError",s=>{console.warn(`[gateway] h2 sessionError: ${s.code??s.message}`)})}await new Promise(i=>{t.listen(e,()=>{const s=t.address()?.port??e;R.push(s),console.log(`[gateway] Listening on ${k?"https":"http"}://localhost:${s}`),i()})}),O.push(t)}$&&(x=n(()=>{const e=$();for(const t of O)t.setSecureContext?.({key:e,cert:e})},"refreshCert")),console.log("[gateway] Routes:");for(const e of T){const t=e.pool?`pods@${e.pool.dir}`:e.target;console.log(`[gateway] ${e.host??"(default)"} \u2192 ${t}${e.prependPrefix?` (+${e.prependPrefix})`:""}${e.internal?" [internal: no middleware]":""} [${e.project}]`)}return M&&console.log(`[gateway] CA: ${M} \u2014 one-time setup via the trust executor.`),p.emit("info",`gateway up: ${T.length} routes on ${R.join("/")}`,"cluster-gateway",{kind:"gateway-up",ports:R,routes:T.map(e=>({host:e.host??"(default)",project:e.project,mode:e.pool?"pods":"tcp",internal:!!e.internal,prependPrefix:e.prependPrefix}))}),{ports:R,routes:T,services:d,stop:n(async()=>{if(!a){a=!0,clearInterval(z),clearInterval(Q),p.emit("info","gateway down","cluster-gateway",{kind:"gateway-down"}),L.stop(),I?.close(),(0,workspace_registry_1.unregisterWorkspace)(v);for(const e of d)P.killSpawned(e,"gateway shutdown");await b.reapAll(),await Promise.all(O.map(e=>new Promise(t=>e.close(()=>t())))),C.closeProxy(),C.destroyAgents()}},"stop")}}m(startGateway,"startGateway"),n(startGateway,"startGateway");