@lensmcp/cluster 1.0.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/LICENSE +202 -0
- package/README.md +130 -0
- package/basic-ssl.d.ts +7 -0
- package/basic-ssl.d.ts.map +1 -0
- package/basic-ssl.js +158 -0
- package/build-scope-patterns.d.ts +12 -0
- package/build-scope-patterns.d.ts.map +1 -0
- package/build-scope-patterns.js +40 -0
- package/create-webpack-dev.d.ts +27 -0
- package/create-webpack-dev.d.ts.map +1 -0
- package/create-webpack-dev.js +151 -0
- package/create-webpack-prod.d.ts +28 -0
- package/create-webpack-prod.d.ts.map +1 -0
- package/create-webpack-prod.js +169 -0
- package/executors/build/build.impl.d.ts +19 -0
- package/executors/build/build.impl.d.ts.map +1 -0
- package/executors/build/build.impl.js +98 -0
- package/executors/build/schema.d.ts +35 -0
- package/executors/build/schema.json +135 -0
- package/executors/gateway/gateway.impl.d.ts +23 -0
- package/executors/gateway/gateway.impl.d.ts.map +1 -0
- package/executors/gateway/gateway.impl.js +39 -0
- package/executors/gateway/gateway.lib.d.ts +130 -0
- package/executors/gateway/gateway.lib.d.ts.map +1 -0
- package/executors/gateway/gateway.lib.js +797 -0
- package/executors/gateway/jwks-verify.d.ts +28 -0
- package/executors/gateway/jwks-verify.d.ts.map +1 -0
- package/executors/gateway/jwks-verify.js +121 -0
- package/executors/gateway/main.prod-gateway.d.ts +2 -0
- package/executors/gateway/main.prod-gateway.d.ts.map +1 -0
- package/executors/gateway/main.prod-gateway.js +290 -0
- package/executors/gateway/main.rollout.d.ts +2 -0
- package/executors/gateway/main.rollout.d.ts.map +1 -0
- package/executors/gateway/main.rollout.js +130 -0
- package/executors/gateway/manifest.d.ts +275 -0
- package/executors/gateway/manifest.d.ts.map +1 -0
- package/executors/gateway/manifest.js +344 -0
- package/executors/gateway/prod-gateway.lib.d.ts +58 -0
- package/executors/gateway/prod-gateway.lib.d.ts.map +1 -0
- package/executors/gateway/prod-gateway.lib.js +535 -0
- package/executors/gateway/providers-prod.d.ts +46 -0
- package/executors/gateway/providers-prod.d.ts.map +1 -0
- package/executors/gateway/providers-prod.js +199 -0
- package/executors/gateway/registry-source.d.ts +68 -0
- package/executors/gateway/registry-source.d.ts.map +1 -0
- package/executors/gateway/registry-source.js +131 -0
- package/executors/gateway/rollout-ops.d.ts +54 -0
- package/executors/gateway/rollout-ops.d.ts.map +1 -0
- package/executors/gateway/rollout-ops.js +167 -0
- package/executors/gateway/schema.d.ts +10 -0
- package/executors/gateway/schema.json +30 -0
- package/executors/serve/schema.d.ts +53 -0
- package/executors/serve/schema.json +196 -0
- package/executors/serve/serve.impl.d.ts +25 -0
- package/executors/serve/serve.impl.d.ts.map +1 -0
- package/executors/serve/serve.impl.js +243 -0
- package/executors/trust/schema.d.ts +6 -0
- package/executors/trust/schema.json +20 -0
- package/executors/trust/trust.impl.d.ts +42 -0
- package/executors/trust/trust.impl.d.ts.map +1 -0
- package/executors/trust/trust.impl.js +126 -0
- package/executors.json +24 -0
- package/index.d.ts +4 -0
- package/index.d.ts.map +1 -0
- package/index.js +9 -0
- package/main.devserver.d.ts +16 -0
- package/main.devserver.d.ts.map +1 -0
- package/main.devserver.js +812 -0
- package/package.json +66 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as http from 'node:http';
|
|
2
|
+
import { type AuthMode, type ManifestProvider, type PodProvider, type Route } from './manifest';
|
|
3
|
+
export interface ProdGatewayOptions {
|
|
4
|
+
/** Ports to bind. Default `[8080]` (or `[8443]` with TLS). One Fastify
|
|
5
|
+
* instance per port; all share the route table + providers. */
|
|
6
|
+
ports?: number[];
|
|
7
|
+
manifests: ManifestProvider;
|
|
8
|
+
/** Resolves a service → upstream when a route has no direct `upstream`. */
|
|
9
|
+
pods?: PodProvider;
|
|
10
|
+
/** service → its `x-internal-token` (from a k8s Secret / Vault in prod). */
|
|
11
|
+
serviceKeys?: Record<string, string>;
|
|
12
|
+
/** Validate a `jwt`-mode request; THROW to reject (→ 401). Default: a
|
|
13
|
+
* pass-through with a one-time warning — wire a real JWKS validator in prod. */
|
|
14
|
+
authenticate?: (mode: AuthMode, req: http.IncomingMessage) => void;
|
|
15
|
+
tls?: {
|
|
16
|
+
key: string | Buffer;
|
|
17
|
+
cert: string | Buffer;
|
|
18
|
+
};
|
|
19
|
+
/** Lens bus sink — OPT-IN. Omit (default) for zero observability overhead. */
|
|
20
|
+
emit?: (event: Record<string, unknown>) => void;
|
|
21
|
+
/** Traffic-edge flush period (ms). Default 5000. */
|
|
22
|
+
trafficFlushMs?: number;
|
|
23
|
+
/** Request header that PINS a rollout cohort (sticky canary / debugging).
|
|
24
|
+
* Default `x-lensmcp-version`. The chosen version is echoed on the response. */
|
|
25
|
+
versionHeader?: string;
|
|
26
|
+
/** undici pool tuning passed to reply-from (connections, pipelining…). */
|
|
27
|
+
undici?: {
|
|
28
|
+
connections?: number;
|
|
29
|
+
pipelining?: number;
|
|
30
|
+
keepAliveTimeout?: number;
|
|
31
|
+
};
|
|
32
|
+
/** Return verified subject attributes (e.g. JWT claims) for ABAC routing.
|
|
33
|
+
* MUST verify (signature/exp) — forged claims must not reach the rules. */
|
|
34
|
+
identify?: (req: http.IncomingMessage) => Record<string, unknown> | undefined;
|
|
35
|
+
/** Allowlist of request headers exposed to rules as `header.<name>` attributes. */
|
|
36
|
+
attributeHeaders?: string[];
|
|
37
|
+
/** Take the `ip` attribute from `X-Forwarded-For` (behind a trusted LB) via
|
|
38
|
+
* Fastify `trustProxy`; default false → socket IP. */
|
|
39
|
+
trustProxyIp?: boolean;
|
|
40
|
+
/** Gateway-issued device cookie for sticky bucketing (anonymous-friendly).
|
|
41
|
+
* Omit to disable cookie stickiness. `secret` enables HMAC signing. */
|
|
42
|
+
cookie?: {
|
|
43
|
+
name?: string;
|
|
44
|
+
secret?: string;
|
|
45
|
+
domain?: string;
|
|
46
|
+
maxAge?: number;
|
|
47
|
+
};
|
|
48
|
+
/** Header carrying a client device/user id — the sticky key when no cookie
|
|
49
|
+
* is present (native/mobile/API clients), e.g. `x-device-id`. */
|
|
50
|
+
uidHeader?: string;
|
|
51
|
+
}
|
|
52
|
+
export interface ProdGatewayHandle {
|
|
53
|
+
ports: number[];
|
|
54
|
+
routes: () => Route[];
|
|
55
|
+
stop: () => Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
export declare function startProdGateway(opts: ProdGatewayOptions): Promise<ProdGatewayHandle>;
|
|
58
|
+
//# sourceMappingURL=prod-gateway.lib.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prod-gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/prod-gateway.lib.ts"],"names":[],"mappings":"AA2BA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAIlC,OAAO,EAEL,KAAK,QAAQ,EAAc,KAAK,gBAAgB,EAAE,KAAK,WAAW,EAAqB,KAAK,KAAK,EAClG,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,kBAAkB;IACjC;oEACgE;IAChE,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,gBAAgB,CAAC;IAC5B,2EAA2E;IAC3E,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC;qFACiF;IACjF,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,KAAK,IAAI,CAAC;IACnE,GAAG,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IACtD,8EAA8E;IAC9E,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAChD,oDAAoD;IACpD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;qFACiF;IACjF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,0EAA0E;IAC1E,MAAM,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAGlF;gFAC4E;IAC5E,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;IAC9E,mFAAmF;IACnF,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B;2DACuD;IACvD,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;4EACwE;IACxE,MAAM,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC9E;sEACkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,MAAM,EAAE,MAAM,KAAK,EAAE,CAAC;IACtB,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAYD,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAoY3F"}
|
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.startProdGateway = startProdGateway;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
/**
|
|
6
|
+
* Production gateway runtime — Fastify + @fastify/reply-from (undici) engine.
|
|
7
|
+
*
|
|
8
|
+
* The SAME routing brain as dev (shared `matchRoute` / `buildRouteTable` /
|
|
9
|
+
* manifest schema) composed over the prod providers (a manifest source +
|
|
10
|
+
* endpoint resolution). The HTTP transport differs from dev on purpose: dev
|
|
11
|
+
* uses node:http + http-proxy (it manages unix-socket pods + scale-from-zero),
|
|
12
|
+
* prod uses Fastify + reply-from's undici pool — connection-reusing, HTTP/1.1
|
|
13
|
+
* pipelined, materially faster than node-http-proxy for the high-RPS edge.
|
|
14
|
+
*
|
|
15
|
+
* The exported contract (`startProdGateway` / `ProdGatewayOptions` /
|
|
16
|
+
* `ProdGatewayHandle`) is unchanged from the previous engine, so the entry,
|
|
17
|
+
* the e2e suite, and the perf suite drive it identically — the swap is proven
|
|
18
|
+
* behind a stable seam.
|
|
19
|
+
*
|
|
20
|
+
* It keeps the dev trust boundary verbatim — strip client-supplied trust
|
|
21
|
+
* headers, validate `x-api-key` on internal routes, inject the target's
|
|
22
|
+
* `x-internal-token` — and the header-gated `gateway-request` flow trace, so
|
|
23
|
+
* the lens (when ENABLED via opts.emit; off by default) sees identical events.
|
|
24
|
+
* Per-request upstream selection (rollout cohort + sticky version) rides on
|
|
25
|
+
* reply-from's `getUpstream` hook. WebSocket upgrades are proxied raw (a
|
|
26
|
+
* net-level pipe) with the same trust model — no http-proxy in the prod path.
|
|
27
|
+
*
|
|
28
|
+
* No autoscale / idle-kill / process management here — prod pods are
|
|
29
|
+
* always-on (k8s keeps them up); that machinery is the dev PodProvider's.
|
|
30
|
+
*/
|
|
31
|
+
const node_crypto_1 = require("node:crypto");
|
|
32
|
+
const net = tslib_1.__importStar(require("node:net"));
|
|
33
|
+
const fastify_1 = tslib_1.__importDefault(require("fastify"));
|
|
34
|
+
const reply_from_1 = tslib_1.__importDefault(require("@fastify/reply-from"));
|
|
35
|
+
const manifest_1 = require("./manifest");
|
|
36
|
+
const eid = () => Date.now().toString(36) + (0, node_crypto_1.randomBytes)(6).toString('hex');
|
|
37
|
+
const hdr = (v) => typeof v === 'string' && v !== '' ? v : Array.isArray(v) ? v[0] : undefined;
|
|
38
|
+
async function startProdGateway(opts) {
|
|
39
|
+
const ports = opts.ports?.length ? opts.ports : [opts.tls ? 8443 : 8080];
|
|
40
|
+
let warnedNoAuth = false;
|
|
41
|
+
const authenticate = opts.authenticate ?? ((mode) => {
|
|
42
|
+
if (mode === 'jwt' && !warnedNoAuth) {
|
|
43
|
+
warnedNoAuth = true;
|
|
44
|
+
console.warn('[gateway] no JWT authenticator configured — jwt routes pass through. Wire opts.authenticate for production.');
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
const emit = (severity, title, fingerprint, raw, ctx) => {
|
|
48
|
+
if (!opts.emit)
|
|
49
|
+
return; // lens off by default — no work, no allocation beyond the call
|
|
50
|
+
try {
|
|
51
|
+
opts.emit({
|
|
52
|
+
id: eid(), sessionId: 'pending', timestamp: Date.now(),
|
|
53
|
+
source: 'gateway', category: 'cluster', severity,
|
|
54
|
+
context: { sessionId: 'pending', ...ctx }, fingerprint, title, raw,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch { /* never let the bus break traffic */ }
|
|
58
|
+
};
|
|
59
|
+
// ---- route table (rebuilt atomically from the manifest source) ----
|
|
60
|
+
let manifests = opts.manifests.list();
|
|
61
|
+
let routes = (0, manifest_1.buildRouteTable)(manifests);
|
|
62
|
+
let manifestByService = new Map(manifests.map((m) => [m.service, m]));
|
|
63
|
+
const rebuild = (next) => {
|
|
64
|
+
manifests = next;
|
|
65
|
+
routes = (0, manifest_1.buildRouteTable)(next);
|
|
66
|
+
manifestByService = new Map(next.map((m) => [m.service, m]));
|
|
67
|
+
emit('info', `gateway routes: ${routes.length} on ${manifests.length} services`, 'gateway-up', { kind: 'gateway-up', routes: routes.map((r) => ({ host: r.host ?? '(default)', service: r.service, internal: !!r.internal })) });
|
|
68
|
+
};
|
|
69
|
+
const unwatch = opts.manifests.watch?.((next) => rebuild(next));
|
|
70
|
+
const keyToProject = new Map(Object.entries(opts.serviceKeys ?? {}).map(([p, k]) => [k, p]));
|
|
71
|
+
const versionHeader = (opts.versionHeader ?? 'x-lensmcp-version').toLowerCase();
|
|
72
|
+
// ---- targeted rollout: attributes + sticky device key (cookie → uid header) ----
|
|
73
|
+
const attrHeaders = (opts.attributeHeaders ?? []).map((h) => h.toLowerCase());
|
|
74
|
+
const uidHeader = opts.uidHeader?.toLowerCase();
|
|
75
|
+
const cookieOn = !!opts.cookie;
|
|
76
|
+
const cookieName = opts.cookie?.name ?? 'lensmcp_did';
|
|
77
|
+
const cookieSecret = opts.cookie?.secret;
|
|
78
|
+
const cookieDomain = opts.cookie?.domain;
|
|
79
|
+
const cookieMaxAge = opts.cookie?.maxAge ?? 31_536_000; // 1y
|
|
80
|
+
if (cookieOn && !cookieSecret)
|
|
81
|
+
console.warn('[gateway] device cookie has no secret — values are UNSIGNED (set cookie.secret to prevent bucket-grinding).');
|
|
82
|
+
const signId = (id) => (0, node_crypto_1.createHmac)('sha256', cookieSecret).update(id).digest('base64url');
|
|
83
|
+
const signedCookie = (id) => (cookieSecret ? `${id}.${signId(id)}` : id);
|
|
84
|
+
const verifyCookie = (val) => {
|
|
85
|
+
if (!cookieSecret)
|
|
86
|
+
return val || undefined; // unsigned mode
|
|
87
|
+
const i = val.lastIndexOf('.');
|
|
88
|
+
if (i < 0)
|
|
89
|
+
return undefined;
|
|
90
|
+
const id = val.slice(0, i);
|
|
91
|
+
const a = Buffer.from(val.slice(i + 1)), b = Buffer.from(signId(id));
|
|
92
|
+
return a.length === b.length && (0, node_crypto_1.timingSafeEqual)(a, b) ? id : undefined;
|
|
93
|
+
};
|
|
94
|
+
const parseCookie = (header, name) => {
|
|
95
|
+
if (!header)
|
|
96
|
+
return undefined;
|
|
97
|
+
for (const part of header.split(';')) {
|
|
98
|
+
const eq = part.indexOf('=');
|
|
99
|
+
if (eq > 0 && part.slice(0, eq).trim() === name)
|
|
100
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
};
|
|
104
|
+
const mintCookieHeader = (id) => {
|
|
105
|
+
let c = `${cookieName}=${encodeURIComponent(signedCookie(id))}; Path=/; Max-Age=${cookieMaxAge}; HttpOnly; SameSite=Lax; Secure`;
|
|
106
|
+
if (cookieDomain)
|
|
107
|
+
c += `; Domain=${cookieDomain}`;
|
|
108
|
+
return c;
|
|
109
|
+
};
|
|
110
|
+
/** Resolve the sticky key: valid cookie → UID header → freshly minted (cookie clients). */
|
|
111
|
+
const resolveStickyKey = (req) => {
|
|
112
|
+
if (cookieOn) {
|
|
113
|
+
const c = parseCookie(req.headers.cookie, cookieName);
|
|
114
|
+
const id = c ? verifyCookie(c) : undefined;
|
|
115
|
+
if (id)
|
|
116
|
+
return { key: id, mint: false };
|
|
117
|
+
}
|
|
118
|
+
if (uidHeader) {
|
|
119
|
+
const u = hdr(req.headers[uidHeader]);
|
|
120
|
+
if (u)
|
|
121
|
+
return { key: u, mint: false };
|
|
122
|
+
}
|
|
123
|
+
if (cookieOn)
|
|
124
|
+
return { key: 'd_' + (0, node_crypto_1.randomBytes)(12).toString('base64url'), mint: true };
|
|
125
|
+
return { mint: false };
|
|
126
|
+
};
|
|
127
|
+
/** Assemble the flat attribute bag a rule evaluates against. */
|
|
128
|
+
const buildAttrs = (request, principal, key) => {
|
|
129
|
+
const req = request.raw;
|
|
130
|
+
const a = { ...(principal ?? {}) };
|
|
131
|
+
a['ip'] = request.ip;
|
|
132
|
+
a['method'] = (req.method ?? 'GET').toUpperCase();
|
|
133
|
+
a['path'] = (req.url ?? '/').split('?')[0];
|
|
134
|
+
if (key)
|
|
135
|
+
a['device'] = key;
|
|
136
|
+
for (const h of attrHeaders) {
|
|
137
|
+
const v = hdr(req.headers[h]);
|
|
138
|
+
if (v !== undefined)
|
|
139
|
+
a['header.' + h] = v;
|
|
140
|
+
}
|
|
141
|
+
return a;
|
|
142
|
+
};
|
|
143
|
+
// ---- traffic edges (host→service[@version]), flushed to the lens periodically ----
|
|
144
|
+
const edges = new Map();
|
|
145
|
+
const recordEdge = (route, ms, status, caller, version) => {
|
|
146
|
+
const host = route.host ?? '(default)';
|
|
147
|
+
const key = (caller ? caller + '⇒' : '') + host + '→' + route.service + (version ? '@' + version : '');
|
|
148
|
+
const e = edges.get(key) ?? { host, service: route.service, caller, version, count: 0, totalMs: 0, errors: 0 };
|
|
149
|
+
e.count += 1;
|
|
150
|
+
e.totalMs += ms;
|
|
151
|
+
if (status >= 500 || status === 0)
|
|
152
|
+
e.errors += 1;
|
|
153
|
+
edges.set(key, e);
|
|
154
|
+
};
|
|
155
|
+
const edgeFlusher = setInterval(() => {
|
|
156
|
+
if (!opts.emit) {
|
|
157
|
+
edges.clear();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
for (const e of edges.values()) {
|
|
161
|
+
emit(e.errors ? 'warning' : 'info', `traffic ${e.host} → ${e.service}${e.version ? '@' + e.version : ''}: ${e.count} req`, `gateway-traffic:${e.host}->${e.service}${e.version ? '@' + e.version : ''}`, { kind: 'gateway-traffic', host: e.host, project: e.service, caller: e.caller, version: e.version, count: e.count, avgMs: Math.round(e.totalMs / e.count), errors: e.errors });
|
|
162
|
+
}
|
|
163
|
+
edges.clear();
|
|
164
|
+
}, opts.trafficFlushMs ?? 5000);
|
|
165
|
+
edgeFlusher.unref?.();
|
|
166
|
+
// ---- per-request flow trace (only for flow-header-carrying requests) ----
|
|
167
|
+
const beginTrace = (req) => {
|
|
168
|
+
if (!opts.emit)
|
|
169
|
+
return undefined; // no lens → no trace overhead
|
|
170
|
+
const flowId = hdr(req.headers['x-lensmcp-flow-id']);
|
|
171
|
+
let requestId = hdr(req.headers['x-request-id']);
|
|
172
|
+
if (!flowId && !requestId && !hdr(req.headers['traceparent']))
|
|
173
|
+
return undefined;
|
|
174
|
+
if (!requestId) {
|
|
175
|
+
requestId = eid();
|
|
176
|
+
req.headers['x-request-id'] = requestId;
|
|
177
|
+
}
|
|
178
|
+
return { startedAt: Date.now(), method: (req.method ?? 'GET').toUpperCase(), host: (req.headers.host || '').split(':')[0], url: req.url ?? '/', steps: [], ...(flowId ? { flowId } : {}), ...(requestId ? { requestId } : {}) };
|
|
179
|
+
};
|
|
180
|
+
const step = (t, name, info) => { if (t)
|
|
181
|
+
t.steps.push({ step: name, atMs: Date.now() - t.startedAt, ...info }); };
|
|
182
|
+
const finish = (t, service, status, extra) => {
|
|
183
|
+
if (!t || t.done)
|
|
184
|
+
return;
|
|
185
|
+
t.done = true;
|
|
186
|
+
emit(status >= 500 ? 'error' : 'info', `gateway ${t.method} ${t.host}${t.url} → ${status} (${Date.now() - t.startedAt}ms)`, `gateway-request:${service}`, { kind: 'gateway-request', project: service, host: t.host, method: t.method, url: t.url, status, durationMs: Date.now() - t.startedAt, startedAt: t.startedAt, steps: t.steps, ...extra }, { ...(t.flowId ? { flowId: t.flowId } : {}), ...(t.requestId ? { requestId: t.requestId } : {}) });
|
|
187
|
+
};
|
|
188
|
+
rebuild(manifests); // emit initial gateway-up
|
|
189
|
+
// ---- trust boundary (verbatim dev model) ----
|
|
190
|
+
// The gateway OWNS these headers — drop any client-supplied copy at ingress so identity
|
|
191
|
+
// can only originate from a verified token (closes header spoofing), then re-stamp on forward.
|
|
192
|
+
const TRUST_HEADERS = ['x-internal-token', 'x-api-key-id', 'x-user-id', 'x-tenant-id', 'x-user-roles', 'x-user-permissions'];
|
|
193
|
+
const stripTrust = (req) => { for (const h of TRUST_HEADERS)
|
|
194
|
+
delete req.headers[h]; };
|
|
195
|
+
/** Stamp the verified identity onto the forwarded request — tenant from the `tid` CLAIM, never a client header. */
|
|
196
|
+
const stampIdentity = (req, claims) => {
|
|
197
|
+
const set = (k, v) => { if (v !== undefined && v !== null && v !== '')
|
|
198
|
+
req.headers[k] = String(v); };
|
|
199
|
+
set('x-user-id', claims['sub']);
|
|
200
|
+
set('x-tenant-id', claims['tid']);
|
|
201
|
+
const roles = claims['roles'];
|
|
202
|
+
if (Array.isArray(roles) && roles.length)
|
|
203
|
+
req.headers['x-user-roles'] = roles.map(String).join(',');
|
|
204
|
+
const perms = claims['perms'];
|
|
205
|
+
if (Array.isArray(perms) && perms.length)
|
|
206
|
+
req.headers['x-user-permissions'] = perms.map(String).join(',');
|
|
207
|
+
};
|
|
208
|
+
// ---- per-request upstream resolution (direct upstream OR pod pick + sticky version) ----
|
|
209
|
+
const resolveUpstream = async (route, trace, pinned) => {
|
|
210
|
+
if (route.upstream)
|
|
211
|
+
return { url: route.upstream };
|
|
212
|
+
const opt = pinned ? { version: pinned } : undefined;
|
|
213
|
+
let up = opts.pods?.pick(route.service, opt);
|
|
214
|
+
if (!up && opts.pods?.ensureUp) {
|
|
215
|
+
step(trace, 'ensure-up', { project: route.service });
|
|
216
|
+
if (await opts.pods.ensureUp(route.service))
|
|
217
|
+
up = opts.pods.pick(route.service, opt);
|
|
218
|
+
}
|
|
219
|
+
return up;
|
|
220
|
+
};
|
|
221
|
+
const reqState = new WeakMap();
|
|
222
|
+
// ---- no-route fallbacks: CORS preflight, health, discovery, 404 ----
|
|
223
|
+
const setCors = (reply, req) => {
|
|
224
|
+
reply.header('access-control-allow-origin', String(req.headers.origin ?? '*'));
|
|
225
|
+
reply.header('access-control-allow-credentials', 'true');
|
|
226
|
+
reply.header('vary', 'Origin');
|
|
227
|
+
};
|
|
228
|
+
const handleNoRoute = (request, reply, host, url) => {
|
|
229
|
+
const req = request.raw;
|
|
230
|
+
setCors(reply, req);
|
|
231
|
+
if (req.method === 'OPTIONS') {
|
|
232
|
+
reply.code(204)
|
|
233
|
+
.header('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS')
|
|
234
|
+
.header('access-control-allow-headers', String(req.headers['access-control-request-headers'] ?? '*'))
|
|
235
|
+
.send();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const mounted = routes.filter((r) => r.host && (0, manifest_1.hostMatches)(r.host, host) && r.prefix);
|
|
239
|
+
const p = url.split('?')[0];
|
|
240
|
+
if (mounted.length > 0 && (p === '/health' || p === '/healthz')) {
|
|
241
|
+
reply.code(200).type('application/json').send(JSON.stringify({ status: 'ok', host, services: mounted.map((r) => ({ service: r.service, prefix: r.prefix })) }));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (p === '/.well-known/lensmcp-routes') {
|
|
245
|
+
reply.code(200).type('application/json').send(JSON.stringify({ host, routes: routes.filter((r) => !r.host || (0, manifest_1.hostMatches)(r.host, host)).map((r) => ({ host: r.host ?? '(default)', service: r.service, prefix: r.prefix, internal: !!r.internal, auth: r.auth })) }));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
reply.code(404).type('text/plain; charset=utf-8').send(mounted.length > 0 ? `No service mounted at ${url} on ${host}.` : 'No gateway route for this hostname.');
|
|
249
|
+
};
|
|
250
|
+
// ---- the request handler (catch-all): route → trust → auth → forward ----
|
|
251
|
+
const handler = async (request, reply) => {
|
|
252
|
+
const req = request.raw;
|
|
253
|
+
const host = (req.headers.host || '').split(':')[0];
|
|
254
|
+
const url = req.url ?? '/';
|
|
255
|
+
const trace = beginTrace(req);
|
|
256
|
+
step(trace, 'received', { host, method: req.method, url });
|
|
257
|
+
const route = (0, manifest_1.matchRoute)(routes, host, url);
|
|
258
|
+
if (!route) {
|
|
259
|
+
handleNoRoute(request, reply, host, url);
|
|
260
|
+
return reply;
|
|
261
|
+
}
|
|
262
|
+
step(trace, 'route-match', { project: route.service, ...(route.prefix ? { prefix: route.prefix } : {}), ...(route.internal ? { internal: true } : {}) });
|
|
263
|
+
// Verify the end-user JWT ONCE (when a verifier is configured) — reused for both edge
|
|
264
|
+
// authorization and rollout targeting. Internal east-west routes use x-api-key instead.
|
|
265
|
+
const claims = route.internal ? undefined : opts.identify?.(req);
|
|
266
|
+
let caller;
|
|
267
|
+
if (route.internal) {
|
|
268
|
+
caller = keyToProject.get(String(req.headers['x-api-key'] ?? ''));
|
|
269
|
+
if (!caller) {
|
|
270
|
+
step(trace, 'auth', { mode: 'x-api-key', ok: false });
|
|
271
|
+
finish(trace, route.service, 401);
|
|
272
|
+
reply.code(401).type('text/plain').send('Invalid or missing x-api-key for internal route.');
|
|
273
|
+
return reply;
|
|
274
|
+
}
|
|
275
|
+
req.headers['x-api-key-id'] = caller;
|
|
276
|
+
delete req.headers['x-api-key'];
|
|
277
|
+
step(trace, 'auth', { mode: 'x-api-key', ok: true, caller });
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
const m = manifestByService.get(route.service);
|
|
281
|
+
const resolved = m ? (0, manifest_1.authRuleForPath)(m, url, req.method ?? 'GET', false) : { mode: route.auth };
|
|
282
|
+
if (resolved.mode === 'jwt') {
|
|
283
|
+
if (opts.identify) {
|
|
284
|
+
if (!claims) {
|
|
285
|
+
step(trace, 'auth', { mode: 'jwt', ok: false });
|
|
286
|
+
finish(trace, route.service, 401);
|
|
287
|
+
reply.code(401).type('text/plain').send('Unauthorized: invalid or missing token');
|
|
288
|
+
return reply;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
try {
|
|
293
|
+
authenticate('jwt', req);
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
step(trace, 'auth', { mode: 'jwt', ok: false });
|
|
297
|
+
finish(trace, route.service, 401);
|
|
298
|
+
reply.code(401).type('text/plain').send(`Unauthorized: ${e.message}`);
|
|
299
|
+
return reply;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// edge ABAC: the required permission/rule must hold against the VERIFIED claims.
|
|
303
|
+
if (resolved.rule && claims && !(0, manifest_1.evalRule)(resolved.rule, buildAttrs(request, claims, undefined))) {
|
|
304
|
+
step(trace, 'authz', { ok: false });
|
|
305
|
+
finish(trace, route.service, 403);
|
|
306
|
+
reply.code(403).type('text/plain').send('Forbidden: insufficient permission');
|
|
307
|
+
return reply;
|
|
308
|
+
}
|
|
309
|
+
step(trace, 'auth', { mode: 'jwt', ok: true });
|
|
310
|
+
if (claims)
|
|
311
|
+
stampIdentity(req, claims);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
step(trace, 'auth', { mode: resolved.mode });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (opts.serviceKeys?.[route.service])
|
|
318
|
+
req.headers['x-internal-token'] = opts.serviceKeys[route.service];
|
|
319
|
+
if (route.prependPrefix && !req.url?.startsWith(route.prependPrefix))
|
|
320
|
+
req.url = route.prependPrefix + (req.url ?? '/');
|
|
321
|
+
// ── version selection: explicit pin → ABAC rule → sticky-key weighted → SWRR ──
|
|
322
|
+
// (targeting/stickiness only for external routes; internal east-west has no "device")
|
|
323
|
+
const explicit = hdr(req.headers[versionHeader]);
|
|
324
|
+
let pinned = explicit;
|
|
325
|
+
let mintKey; // set → append Set-Cookie on the response
|
|
326
|
+
if (!route.internal && !explicit) {
|
|
327
|
+
const sk = resolveStickyKey(req);
|
|
328
|
+
if (sk.mint)
|
|
329
|
+
mintKey = sk.key;
|
|
330
|
+
const attrs = buildAttrs(request, claims, sk.key);
|
|
331
|
+
const targeted = opts.pods?.resolveVersion?.(route.service, attrs);
|
|
332
|
+
if (targeted) {
|
|
333
|
+
pinned = targeted.version;
|
|
334
|
+
step(trace, 'targeting', { rule: targeted.rule, version: targeted.version });
|
|
335
|
+
}
|
|
336
|
+
else if (sk.key) {
|
|
337
|
+
const b = opts.pods?.bucket?.(route.service, sk.key);
|
|
338
|
+
if (b) {
|
|
339
|
+
pinned = b.version;
|
|
340
|
+
step(trace, 'targeting', { mode: 'sticky', version: b.version });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const up = await resolveUpstream(route, trace, pinned);
|
|
345
|
+
if (!up) {
|
|
346
|
+
finish(trace, route.service, 503);
|
|
347
|
+
reply.code(503).type('text/plain').send(`Service ${route.service} has no available upstream.`);
|
|
348
|
+
return reply;
|
|
349
|
+
}
|
|
350
|
+
if (!('url' in up)) {
|
|
351
|
+
finish(trace, route.service, 502);
|
|
352
|
+
reply.code(502).type('text/plain').send(`Service ${route.service}: socket upstreams are not supported by the prod gateway.`);
|
|
353
|
+
return reply;
|
|
354
|
+
}
|
|
355
|
+
const origin = new URL(up.url).origin;
|
|
356
|
+
const upLabel = up.url;
|
|
357
|
+
reqState.set(request, { route, caller, startedAt: Date.now(), trace, version: up.version, upLabel });
|
|
358
|
+
step(trace, up.version ? 'rollout' : 'upstream', { upstream: upLabel, ...(up.version ? { version: up.version, ...(explicit ? { pinned: true } : {}) } : {}) });
|
|
359
|
+
// No source → reply-from forwards the (prefix-rewritten) req.url to `origin`.
|
|
360
|
+
return reply.from(undefined, {
|
|
361
|
+
getUpstream: () => origin,
|
|
362
|
+
rewriteHeaders: (headers) => {
|
|
363
|
+
headers['x-lensmcp-upstream'] = upLabel;
|
|
364
|
+
if (up.version)
|
|
365
|
+
headers['x-lensmcp-version'] = up.version; // which rollout cohort served
|
|
366
|
+
// The gateway owns the edge response: drop the upstream framework leak
|
|
367
|
+
// (e.g. NestJS/Express `x-powered-by`) and brand the hop as the LensMCP gateway.
|
|
368
|
+
delete headers['x-powered-by'];
|
|
369
|
+
headers['server'] = 'LensMCP';
|
|
370
|
+
if (mintKey) { // first contact → stamp the device cookie (appended to any upstream Set-Cookie)
|
|
371
|
+
const ex = headers['set-cookie'];
|
|
372
|
+
const list = Array.isArray(ex) ? ex : ex ? [ex] : [];
|
|
373
|
+
headers['set-cookie'] = [...list, mintCookieHeader(mintKey)];
|
|
374
|
+
}
|
|
375
|
+
return headers;
|
|
376
|
+
},
|
|
377
|
+
onError: (rep, { error }) => {
|
|
378
|
+
opts.pods?.drop?.(route.service, up);
|
|
379
|
+
rep.code(502).type('text/plain').send(`Upstream ${route.service} unavailable — ${error.message}`);
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
};
|
|
383
|
+
// ---- raw WebSocket upgrade proxy (same trust model; net-level pipe) ----
|
|
384
|
+
const upgrade = (req, socket, head) => {
|
|
385
|
+
stripTrust(req);
|
|
386
|
+
const host = (req.headers.host || '').split(':')[0];
|
|
387
|
+
const route = (0, manifest_1.matchRoute)(routes, host, req.url ?? '/');
|
|
388
|
+
if (!route) {
|
|
389
|
+
socket.destroy();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const claims = route.internal ? undefined : opts.identify?.(req);
|
|
393
|
+
let caller;
|
|
394
|
+
if (route.internal) {
|
|
395
|
+
caller = keyToProject.get(String(req.headers['x-api-key'] ?? ''));
|
|
396
|
+
if (!caller) {
|
|
397
|
+
socket.destroy();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
const m = manifestByService.get(route.service);
|
|
403
|
+
const resolved = m ? (0, manifest_1.authRuleForPath)(m, req.url ?? '/', req.method ?? 'GET', false) : { mode: route.auth };
|
|
404
|
+
if (resolved.mode === 'jwt') {
|
|
405
|
+
if (opts.identify) {
|
|
406
|
+
if (!claims) {
|
|
407
|
+
socket.destroy();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
try {
|
|
413
|
+
authenticate('jwt', req);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
socket.destroy();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (resolved.rule && claims && !(0, manifest_1.evalRule)(resolved.rule, { ...claims, ip: req.socket.remoteAddress ?? '', method: req.method ?? 'GET', path: (req.url ?? '/').split('?')[0] })) {
|
|
421
|
+
socket.destroy();
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (route.prependPrefix && !req.url?.startsWith(route.prependPrefix))
|
|
427
|
+
req.url = route.prependPrefix + (req.url ?? '/');
|
|
428
|
+
const pinned = hdr(req.headers[versionHeader]);
|
|
429
|
+
void (async () => {
|
|
430
|
+
const up = await resolveUpstream(route, undefined, pinned);
|
|
431
|
+
if (!up || !('url' in up)) {
|
|
432
|
+
socket.destroy();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const u = new URL(up.url);
|
|
436
|
+
const upstream = net.connect(Number(u.port) || (u.protocol === 'https:' ? 443 : 80), u.hostname, () => {
|
|
437
|
+
// rebuild the upgrade request from rawHeaders (verbatim), minus the trust headers
|
|
438
|
+
// we own (incl. client identity), plus the validated caller + target token + identity.
|
|
439
|
+
const strip = new Set(['x-internal-token', 'x-api-key-id', 'x-api-key', 'x-user-id', 'x-tenant-id', 'x-user-roles', 'x-user-permissions']);
|
|
440
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`];
|
|
441
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
442
|
+
const k = req.rawHeaders[i];
|
|
443
|
+
if (!strip.has(k.toLowerCase()))
|
|
444
|
+
lines.push(`${k}: ${req.rawHeaders[i + 1]}`);
|
|
445
|
+
}
|
|
446
|
+
if (caller)
|
|
447
|
+
lines.push(`x-api-key-id: ${caller}`);
|
|
448
|
+
const token = opts.serviceKeys?.[route.service];
|
|
449
|
+
if (token)
|
|
450
|
+
lines.push(`x-internal-token: ${token}`);
|
|
451
|
+
if (claims) { // stamp the verified identity (mirrors the HTTP path's stampIdentity)
|
|
452
|
+
if (claims['sub'])
|
|
453
|
+
lines.push(`x-user-id: ${String(claims['sub'])}`);
|
|
454
|
+
if (claims['tid'])
|
|
455
|
+
lines.push(`x-tenant-id: ${String(claims['tid'])}`);
|
|
456
|
+
const roles = claims['roles'];
|
|
457
|
+
if (Array.isArray(roles) && roles.length)
|
|
458
|
+
lines.push(`x-user-roles: ${roles.map(String).join(',')}`);
|
|
459
|
+
const perms = claims['perms'];
|
|
460
|
+
if (Array.isArray(perms) && perms.length)
|
|
461
|
+
lines.push(`x-user-permissions: ${perms.map(String).join(',')}`);
|
|
462
|
+
}
|
|
463
|
+
upstream.write(lines.join('\r\n') + '\r\n\r\n');
|
|
464
|
+
if (head?.length)
|
|
465
|
+
upstream.write(head);
|
|
466
|
+
socket.pipe(upstream);
|
|
467
|
+
upstream.pipe(socket);
|
|
468
|
+
});
|
|
469
|
+
upstream.on('error', () => socket.destroy());
|
|
470
|
+
socket.on('error', () => upstream.destroy());
|
|
471
|
+
})();
|
|
472
|
+
};
|
|
473
|
+
// ---- build one Fastify app per port (shared route table + providers) ----
|
|
474
|
+
const buildApp = async () => {
|
|
475
|
+
const app = (0, fastify_1.default)({ logger: false, ...(opts.trustProxyIp ? { trustProxy: true } : {}), ...(opts.tls ? { https: { key: opts.tls.key, cert: opts.tls.cert } } : {}) });
|
|
476
|
+
// Stream every body straight through — never parse/buffer (this is a proxy).
|
|
477
|
+
app.removeAllContentTypeParsers();
|
|
478
|
+
app.addContentTypeParser('*', (_req, payload, done) => done(null, payload));
|
|
479
|
+
await app.register(reply_from_1.default, {
|
|
480
|
+
disableRequestLogging: true,
|
|
481
|
+
undici: {
|
|
482
|
+
connections: opts.undici?.connections ?? 256,
|
|
483
|
+
pipelining: opts.undici?.pipelining ?? 1,
|
|
484
|
+
keepAliveTimeout: opts.undici?.keepAliveTimeout ?? 60_000,
|
|
485
|
+
connect: { rejectUnauthorized: false }, // upstreams may use the local CA / self-signed (mirrors http-proxy secure:false)
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
// Host-agnostic operational probes (answered before routing).
|
|
489
|
+
app.get('/livez', (_req, reply) => { reply.type('text/plain').send('ok'); });
|
|
490
|
+
app.get('/readyz', (_req, reply) => {
|
|
491
|
+
const ready = routes.length > 0;
|
|
492
|
+
reply.code(ready ? 200 : 503).type('text/plain').send(ready ? 'ready' : 'no routes loaded');
|
|
493
|
+
});
|
|
494
|
+
app.addHook('onRequest', (request, _reply, done) => { stripTrust(request.raw); done(); });
|
|
495
|
+
app.addHook('onResponse', (request, reply, done) => {
|
|
496
|
+
const st = reqState.get(request);
|
|
497
|
+
if (st) {
|
|
498
|
+
recordEdge(st.route, Date.now() - st.startedAt, reply.statusCode ?? 0, st.caller, st.version);
|
|
499
|
+
finish(st.trace, st.route.service, reply.statusCode ?? 0, { upstream: st.upLabel, ...(st.version ? { version: st.version } : {}), ...(st.caller ? { caller: st.caller } : {}) });
|
|
500
|
+
reqState.delete(request);
|
|
501
|
+
}
|
|
502
|
+
done();
|
|
503
|
+
});
|
|
504
|
+
app.all('/', handler);
|
|
505
|
+
app.all('/*', handler);
|
|
506
|
+
app.server.on('upgrade', upgrade);
|
|
507
|
+
return app;
|
|
508
|
+
};
|
|
509
|
+
// ---- listeners ----
|
|
510
|
+
const apps = [];
|
|
511
|
+
const boundPorts = [];
|
|
512
|
+
for (const port of ports) {
|
|
513
|
+
const app = await buildApp();
|
|
514
|
+
await app.listen({ port, host: '0.0.0.0' });
|
|
515
|
+
const addr = app.server.address();
|
|
516
|
+
const bound = typeof addr === 'object' && addr ? addr.port : port;
|
|
517
|
+
boundPorts.push(bound);
|
|
518
|
+
console.log(`[gateway] listening on ${opts.tls ? 'https' : 'http'}://0.0.0.0:${bound}`);
|
|
519
|
+
apps.push(app);
|
|
520
|
+
}
|
|
521
|
+
let stopped = false;
|
|
522
|
+
return {
|
|
523
|
+
ports: boundPorts,
|
|
524
|
+
routes: () => routes,
|
|
525
|
+
stop: async () => {
|
|
526
|
+
if (stopped)
|
|
527
|
+
return;
|
|
528
|
+
stopped = true;
|
|
529
|
+
clearInterval(edgeFlusher);
|
|
530
|
+
unwatch?.();
|
|
531
|
+
emit('info', 'gateway down', 'gateway-down', { kind: 'gateway-down' });
|
|
532
|
+
await Promise.all(apps.map((a) => a.close())); // reply-from closes its undici pool on close
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type ManifestProvider, type PodProvider, type RouteManifest, type VersionCohort, type TargetRule } from './manifest';
|
|
2
|
+
/** A service's rollout spec: optional targeting rules + the weighted cohorts. */
|
|
3
|
+
export interface RolloutSpec {
|
|
4
|
+
rules?: TargetRule[];
|
|
5
|
+
cohorts: VersionCohort[];
|
|
6
|
+
}
|
|
7
|
+
/** Per-service rollout table — a bare cohort array (no rules) or a full spec. */
|
|
8
|
+
export type RolloutTable = Record<string, VersionCohort[] | RolloutSpec>;
|
|
9
|
+
/** A fixed set of manifests (tests, single-shot config). */
|
|
10
|
+
export declare function staticManifestProvider(manifests: RouteManifest[]): ManifestProvider;
|
|
11
|
+
/**
|
|
12
|
+
* Watch a manifests JSON file; atomically swap the in-memory set on a valid
|
|
13
|
+
* change, reject and keep last-good on a bad one. `watch` notifies subscribers
|
|
14
|
+
* with the new set. Poll fallback covers editors/atomic-rename + platforms
|
|
15
|
+
* where fs.watch is unreliable.
|
|
16
|
+
*/
|
|
17
|
+
export declare function fileManifestProvider(filePath: string, opts?: {
|
|
18
|
+
pollMs?: number;
|
|
19
|
+
onError?: (e: unknown) => void;
|
|
20
|
+
}): ManifestProvider & {
|
|
21
|
+
stop: () => void;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Rollout-aware pod provider: each service is a set of VERSION COHORTS, and
|
|
25
|
+
* `pick` splits traffic across them by weight (smooth weighted round-robin —
|
|
26
|
+
* a 90/10 canary interleaves, never bursts) and round-robins endpoints WITHIN
|
|
27
|
+
* the chosen cohort. `pick(svc, {version})` pins a cohort (sticky canary /
|
|
28
|
+
* debugging). `update` swaps weights/cohorts live — a rollout ramps
|
|
29
|
+
* (10→25→50→100) or rolls back without redeploying the gateway. The returned
|
|
30
|
+
* Upstream carries `version` so the gateway can surface which cohort served.
|
|
31
|
+
*/
|
|
32
|
+
export declare function rolloutPodProvider(table: RolloutTable): PodProvider & {
|
|
33
|
+
update: (next: RolloutTable) => void;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Flat single-version load balancing — the common case (no rollout in
|
|
37
|
+
* progress). A thin wrapper over `rolloutPodProvider` with one synthetic
|
|
38
|
+
* cohort per service, so the rollout path is the only code path. The synthetic
|
|
39
|
+
* version is STRIPPED from the result: flat LB carries no `version`, so a
|
|
40
|
+
* response only gets `x-lensmcp-version` (and a `rollout` trace step) when a
|
|
41
|
+
* genuine multi-cohort rollout served it.
|
|
42
|
+
*/
|
|
43
|
+
export declare function endpointsPodProvider(endpoints: Record<string, string[]>): PodProvider & {
|
|
44
|
+
update: (next: Record<string, string[]>) => void;
|
|
45
|
+
};
|
|
46
|
+
//# sourceMappingURL=providers-prod.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"providers-prod.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/providers-prod.ts"],"names":[],"mappings":"AAkBA,OAAO,EAEL,KAAK,gBAAgB,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAiB,KAAK,aAAa,EAAE,KAAK,UAAU,EAChH,MAAM,YAAY,CAAC;AAEpB,iFAAiF;AACjF,MAAM,WAAW,WAAW;IAAG,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;IAAC,OAAO,EAAE,aAAa,EAAE,CAAA;CAAE;AAC/E,iFAAiF;AACjF,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,aAAa,EAAE,GAAG,WAAW,CAAC,CAAC;AAIzE,4DAA4D;AAC5D,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,aAAa,EAAE,GAAG,gBAAgB,CAEnF;AAgBD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,MAAM,EAChB,IAAI,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;CAAO,GAC7D,gBAAgB,GAAG;IAAE,IAAI,EAAE,MAAM,IAAI,CAAA;CAAE,CA+BzC;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,YAAY,GAClB,WAAW,GAAG;IAAE,MAAM,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,IAAI,CAAA;CAAE,CAkDxD;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,GAClC,WAAW,GAAG;IAAE,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,KAAK,IAAI,CAAA;CAAE,CAapE"}
|