@lensmcp/cluster 1.18.4 → 1.18.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/basic-ssl.js +1 -241
- package/build-scope-patterns.js +1 -40
- package/create-webpack-dev.js +1 -186
- package/create-webpack-prod.js +1 -169
- package/executors/build/build.impl.js +1 -98
- package/executors/gateway/gateway-errors.js +1 -43
- package/executors/gateway/gateway.impl.js +1 -53
- package/executors/gateway/gateway.lib.js +1 -29
- package/executors/gateway/health-check.js +1 -66
- package/executors/gateway/jwks-verify.js +1 -121
- package/executors/gateway/main.prod-gateway.js +2 -573
- package/executors/gateway/main.rollout.js +11 -117
- package/executors/gateway/manifest.js +1 -374
- package/executors/gateway/metrics.js +1 -56
- package/executors/gateway/otel-tracing.js +1 -74
- package/executors/gateway/prod-gateway.lib.js +1 -22
- package/executors/gateway/prod-runtime/access-log.js +1 -24
- package/executors/gateway/prod-runtime/app.js +1 -123
- package/executors/gateway/prod-runtime/auth.js +1 -51
- package/executors/gateway/prod-runtime/cors.js +1 -40
- package/executors/gateway/prod-runtime/edge.js +1 -65
- package/executors/gateway/prod-runtime/handler.js +1 -226
- package/executors/gateway/prod-runtime/hooks.js +1 -42
- package/executors/gateway/prod-runtime/observability.js +1 -125
- package/executors/gateway/prod-runtime/rollout.js +1 -103
- package/executors/gateway/prod-runtime/routing.js +1 -40
- package/executors/gateway/prod-runtime/server.js +1 -79
- package/executors/gateway/prod-runtime/trust.js +1 -32
- package/executors/gateway/prod-runtime/types.js +1 -2
- package/executors/gateway/prod-runtime/upgrade.js +4 -116
- package/executors/gateway/prod-runtime/upstream.js +1 -21
- package/executors/gateway/providers-prod.js +1 -232
- package/executors/gateway/rate-limit.js +2 -75
- package/executors/gateway/registry-source.js +1 -131
- package/executors/gateway/rollout-ops.js +2 -167
- package/executors/gateway/runtime/auth.js +1 -64
- package/executors/gateway/runtime/chooser.js +12 -45
- package/executors/gateway/runtime/control.js +1 -128
- package/executors/gateway/runtime/dev-auth.js +1 -108
- package/executors/gateway/runtime/discovery.js +1 -123
- package/executors/gateway/runtime/edge.js +1 -47
- package/executors/gateway/runtime/handler.js +1 -183
- package/executors/gateway/runtime/hooks.js +1 -55
- package/executors/gateway/runtime/lens-children.js +1 -651
- package/executors/gateway/runtime/lifecycle.js +3 -842
- package/executors/gateway/runtime/observability.js +2 -148
- package/executors/gateway/runtime/pod-env.js +2 -89
- package/executors/gateway/runtime/proxy.js +1 -457
- package/executors/gateway/runtime/route-registry.js +1 -72
- package/executors/gateway/runtime/scope.js +1 -117
- package/executors/gateway/runtime/server.js +3 -487
- package/executors/gateway/runtime/service-keys.js +1 -49
- package/executors/gateway/runtime/types.js +1 -151
- package/executors/gateway/runtime/upgrade.js +1 -71
- package/executors/gateway/runtime/workspace-registry.js +1 -99
- package/executors/gateway/ssrf-guard.js +1 -190
- package/executors/serve/serve.impl.js +1 -280
- package/executors/trust/trust.impl.js +4 -162
- package/gateway.js +1 -35
- package/index.js +1 -16
- package/main.devserver.js +10 -1117
- package/package.json +4 -3
- package/tsgo-check-plugin.js +4 -364
- package/typecheck-bus.js +4 -256
|
@@ -1,25 +1,4 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.runRollout = runRollout;
|
|
4
|
-
/**
|
|
5
|
-
* `lensmcp rollout` CD helper entry — safe, scriptable edits to the gateway
|
|
6
|
-
* rollout config. Two targets:
|
|
7
|
-
* - a FILE (default): the watched config / mounted ConfigMap. Atomic write
|
|
8
|
-
* (tmp + rename) so the gateway watcher never reads a half file.
|
|
9
|
-
* - a REDIS registry (`--redis <url>`): writes the service's rollout key and
|
|
10
|
-
* PUBLISHES the change channel, so a registry-backed gateway re-reads live
|
|
11
|
-
* (zero-touch auto-sync; no file mounts).
|
|
12
|
-
*
|
|
13
|
-
* node main.rollout.js <config.json> <command> [args]
|
|
14
|
-
* node main.rollout.js --redis <url> [--prefix <p>] <command> [args]
|
|
15
|
-
*
|
|
16
|
-
* Available from the published @lensmcp/cluster package; `lensmcp rollout …`
|
|
17
|
-
* wraps it. See docs/design/targeted-rollout.md.
|
|
18
|
-
*/
|
|
19
|
-
const node_fs_1 = require("node:fs");
|
|
20
|
-
const rollout_ops_1 = require("./rollout-ops");
|
|
21
|
-
const registry_source_1 = require("./registry-source");
|
|
22
|
-
const USAGE = `lensmcp rollout — edit the gateway rollout config (file or Redis registry)
|
|
1
|
+
"use strict";var p=Object.defineProperty;var l=(e,r)=>p(e,"name",{value:r,configurable:!0});var a=Object.defineProperty,i=l((e,r)=>a(e,"name",{value:r,configurable:!0}),"i");Object.defineProperty(exports,"__esModule",{value:!0}),exports.runRollout=runRollout;const node_fs_1=require("node:fs"),rollout_ops_1=require("./rollout-ops"),registry_source_1=require("./registry-source"),USAGE=`lensmcp rollout \u2014 edit the gateway rollout config (file or Redis registry)
|
|
23
2
|
|
|
24
3
|
Usage: <config.json> <command> [args]
|
|
25
4
|
--redis <url> [--prefix <p>] <command> [args]
|
|
@@ -33,98 +12,13 @@ Commands:
|
|
|
33
12
|
abort <svc> <version> set version weight=0 (rollback)
|
|
34
13
|
add-rule <svc> <name> <attr> <op> <value> <version> add/replace a targeting rule
|
|
35
14
|
remove-rule <svc> <name> remove a targeting rule
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
function extractFlag(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
function runFile(argv) {
|
|
47
|
-
const [file, ...rest] = argv;
|
|
48
|
-
if (!file || !rest.length) {
|
|
49
|
-
err(USAGE);
|
|
50
|
-
return 2;
|
|
51
|
-
}
|
|
52
|
-
let config;
|
|
53
|
-
try {
|
|
54
|
-
config = JSON.parse((0, node_fs_1.readFileSync)(file, 'utf8'));
|
|
55
|
-
}
|
|
56
|
-
catch (e) {
|
|
57
|
-
err(`cannot read ${file}: ${e.message}`);
|
|
58
|
-
return 1;
|
|
59
|
-
}
|
|
60
|
-
try {
|
|
61
|
-
const parsed = (0, rollout_ops_1.parseRolloutCommand)(rest);
|
|
62
|
-
if (!parsed.op) {
|
|
63
|
-
out((0, rollout_ops_1.summarizeRollout)(config, parsed.status));
|
|
64
|
-
return 0;
|
|
65
|
-
}
|
|
66
|
-
const next = (0, rollout_ops_1.applyRolloutOp)(config, parsed.op);
|
|
67
|
-
(0, node_fs_1.writeFileSync)(`${file}.tmp`, JSON.stringify(next, null, 2) + '\n');
|
|
68
|
-
(0, node_fs_1.renameSync)(`${file}.tmp`, file); // atomic — the watcher never sees a partial file
|
|
69
|
-
out(`✓ ${parsed.op.kind} ${parsed.op.service}\n\n${(0, rollout_ops_1.summarizeRollout)(next, parsed.op.service)}`);
|
|
70
|
-
return 0;
|
|
71
|
-
}
|
|
72
|
-
catch (e) {
|
|
73
|
-
err(`✗ ${e.message}`);
|
|
74
|
-
return 1;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
async function runRedis(url, prefix, argv) {
|
|
78
|
-
if (!argv.length) {
|
|
79
|
-
err(USAGE);
|
|
80
|
-
return 2;
|
|
81
|
-
}
|
|
82
|
-
let client;
|
|
83
|
-
try {
|
|
84
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
85
|
-
const Redis = require('ioredis');
|
|
86
|
-
client = new Redis(url);
|
|
87
|
-
}
|
|
88
|
-
catch {
|
|
89
|
-
err('--redis needs the "ioredis" package installed (peer dep of @lensmcp/cluster)');
|
|
90
|
-
return 1;
|
|
91
|
-
}
|
|
92
|
-
try {
|
|
93
|
-
const { endpoints } = await (0, registry_source_1.redisRegistrySource)(client, { prefix }).read();
|
|
94
|
-
const parsed = (0, rollout_ops_1.parseRolloutCommand)(argv);
|
|
95
|
-
if (!parsed.op) {
|
|
96
|
-
out((0, rollout_ops_1.summarizeRollout)(endpoints, parsed.status));
|
|
97
|
-
return 0;
|
|
98
|
-
}
|
|
99
|
-
const next = (0, rollout_ops_1.applyRolloutOp)(endpoints, parsed.op);
|
|
100
|
-
const svc = parsed.op.service;
|
|
101
|
-
const entry = next[svc];
|
|
102
|
-
if (entry === undefined)
|
|
103
|
-
await client.del((0, registry_source_1.rolloutKey)(prefix, svc));
|
|
104
|
-
else
|
|
105
|
-
await client.set((0, registry_source_1.rolloutKey)(prefix, svc), JSON.stringify(entry));
|
|
106
|
-
await client.publish((0, registry_source_1.changeChannel)(prefix), svc); // gateway re-reads live
|
|
107
|
-
out(`✓ ${parsed.op.kind} ${svc} → redis ${prefix} (published)\n\n${(0, rollout_ops_1.summarizeRollout)(next, svc)}`);
|
|
108
|
-
return 0;
|
|
109
|
-
}
|
|
110
|
-
catch (e) {
|
|
111
|
-
err(`✗ ${e.message}`);
|
|
112
|
-
return 1;
|
|
113
|
-
}
|
|
114
|
-
finally {
|
|
115
|
-
try {
|
|
116
|
-
await client.quit();
|
|
117
|
-
}
|
|
118
|
-
catch { /* ignore */ }
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
async function runRollout(argv) {
|
|
122
|
-
const redis = extractFlag(argv, '--redis');
|
|
123
|
-
const pref = extractFlag(redis.rest, '--prefix');
|
|
124
|
-
if (redis.value)
|
|
125
|
-
return runRedis(redis.value, pref.value ?? registry_source_1.DEFAULT_REGISTRY_PREFIX, pref.rest);
|
|
126
|
-
return runFile(pref.rest);
|
|
127
|
-
}
|
|
128
|
-
// Run only when invoked directly (not when imported, e.g. by the lensmcp CLI).
|
|
129
|
-
if (require.main === module)
|
|
130
|
-
void runRollout(process.argv.slice(2)).then((code) => process.exit(code));
|
|
15
|
+
`,out=i(e=>process.stdout.write(e.endsWith(`
|
|
16
|
+
`)?e:e+`
|
|
17
|
+
`),"out"),err=i(e=>process.stderr.write(e.endsWith(`
|
|
18
|
+
`)?e:e+`
|
|
19
|
+
`),"err");function extractFlag(e,r){const o=e.indexOf(r);return o<0?{rest:e}:{value:e[o+1],rest:[...e.slice(0,o),...e.slice(o+2)]}}l(extractFlag,"extractFlag"),i(extractFlag,"extractFlag");function runFile(e){const[r,...o]=e;if(!r||!o.length)return err(USAGE),2;let s;try{s=JSON.parse((0,node_fs_1.readFileSync)(r,"utf8"))}catch(t){return err(`cannot read ${r}: ${t.message}`),1}try{const t=(0,rollout_ops_1.parseRolloutCommand)(o);if(!t.op)return out((0,rollout_ops_1.summarizeRollout)(s,t.status)),0;const n=(0,rollout_ops_1.applyRolloutOp)(s,t.op);return(0,node_fs_1.writeFileSync)(`${r}.tmp`,JSON.stringify(n,null,2)+`
|
|
20
|
+
`),(0,node_fs_1.renameSync)(`${r}.tmp`,r),out(`\u2713 ${t.op.kind} ${t.op.service}
|
|
21
|
+
|
|
22
|
+
${(0,rollout_ops_1.summarizeRollout)(n,t.op.service)}`),0}catch(t){return err(`\u2717 ${t.message}`),1}}l(runFile,"runFile"),i(runFile,"runFile");async function runRedis(e,r,o){if(!o.length)return err(USAGE),2;let s;try{const t=require("ioredis");s=new t(e)}catch{return err('--redis needs the "ioredis" package installed (peer dep of @lensmcp/cluster)'),1}try{const{endpoints:t}=await(0,registry_source_1.redisRegistrySource)(s,{prefix:r}).read(),n=(0,rollout_ops_1.parseRolloutCommand)(o);if(!n.op)return out((0,rollout_ops_1.summarizeRollout)(t,n.status)),0;const c=(0,rollout_ops_1.applyRolloutOp)(t,n.op),u=n.op.service,d=c[u];return d===void 0?await s.del((0,registry_source_1.rolloutKey)(r,u)):await s.set((0,registry_source_1.rolloutKey)(r,u),JSON.stringify(d)),await s.publish((0,registry_source_1.changeChannel)(r),u),out(`\u2713 ${n.op.kind} ${u} \u2192 redis ${r} (published)
|
|
23
|
+
|
|
24
|
+
${(0,rollout_ops_1.summarizeRollout)(c,u)}`),0}catch(t){return err(`\u2717 ${t.message}`),1}finally{try{await s.quit()}catch{}}}l(runRedis,"runRedis"),i(runRedis,"runRedis");async function runRollout(e){const r=extractFlag(e,"--redis"),o=extractFlag(r.rest,"--prefix");return r.value?runRedis(r.value,o.value??registry_source_1.DEFAULT_REGISTRY_PREFIX,o.rest):runFile(o.rest)}l(runRollout,"runRollout"),i(runRollout,"runRollout"),require.main===module&&runRollout(process.argv.slice(2)).then(e=>process.exit(e));
|
|
@@ -1,374 +1 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Route manifests + the provider seams that make the gateway ONE library
|
|
4
|
-
* across dev and prod.
|
|
5
|
-
*
|
|
6
|
-
* The dev gateway and the production gateway share this routing core. A
|
|
7
|
-
* service DESCRIBES itself with a `RouteManifest` (in dev, synthesized from
|
|
8
|
-
* the project.json `cluster` field; in prod, published by the service to a
|
|
9
|
-
* registry / served at `/.well-known/lensmcp-routes`). The gateway builds a
|
|
10
|
-
* flat `Route[]` from the manifests and matches requests against it — the
|
|
11
|
-
* SAME `matchRoute` in both environments, so routing can never drift.
|
|
12
|
-
*
|
|
13
|
-
* What differs between dev and prod is supplied through two interfaces:
|
|
14
|
-
* - ManifestProvider — WHERE the manifests come from (workspace scan vs
|
|
15
|
-
* registry watch) and how changes arrive.
|
|
16
|
-
* - PodProvider — HOW a service name resolves to a concrete upstream
|
|
17
|
-
* (unix-socket pool with scale-from-zero vs a TCP endpoint).
|
|
18
|
-
*
|
|
19
|
-
* This module is PURE (no IO, no process, no http) so it is exhaustively
|
|
20
|
-
* unit-testable; the providers carry the environment-specific machinery.
|
|
21
|
-
*/
|
|
22
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
-
exports.smoothWeightedSelector = smoothWeightedSelector;
|
|
24
|
-
exports.hostMatches = hostMatches;
|
|
25
|
-
exports.matchRoute = matchRoute;
|
|
26
|
-
exports.sortBySpecificity = sortBySpecificity;
|
|
27
|
-
exports.authForPath = authForPath;
|
|
28
|
-
exports.authPathMatches = authPathMatches;
|
|
29
|
-
exports.authRuleFor = authRuleFor;
|
|
30
|
-
exports.authRuleForPath = authRuleForPath;
|
|
31
|
-
exports.buildRouteTable = buildRouteTable;
|
|
32
|
-
exports.manifestFromClusterDecl = manifestFromClusterDecl;
|
|
33
|
-
exports.matchCidr = matchCidr;
|
|
34
|
-
exports.evalRule = evalRule;
|
|
35
|
-
exports.pickTargetedVersion = pickTargetedVersion;
|
|
36
|
-
exports.fnv1a = fnv1a;
|
|
37
|
-
exports.bucketByWeight = bucketByWeight;
|
|
38
|
-
/**
|
|
39
|
-
* Smooth weighted round-robin (nginx-style). Returns a stateful selector that,
|
|
40
|
-
* over time, hands out indices in proportion to `weights` — but INTERLEAVED
|
|
41
|
-
* (90/10 yields the rare cohort roughly every tenth pick, not in a burst at
|
|
42
|
-
* the end). Deterministic given the weights, so rollout splits are testable.
|
|
43
|
-
* A zero/negative total (all cohorts drained) falls back to index 0.
|
|
44
|
-
*/
|
|
45
|
-
function smoothWeightedSelector(weights) {
|
|
46
|
-
const cur = weights.map(() => 0);
|
|
47
|
-
const total = weights.reduce((s, w) => s + Math.max(0, w), 0);
|
|
48
|
-
return () => {
|
|
49
|
-
if (total <= 0 || weights.length === 0)
|
|
50
|
-
return 0;
|
|
51
|
-
let best = 0;
|
|
52
|
-
for (let i = 0; i < weights.length; i++) {
|
|
53
|
-
cur[i] += Math.max(0, weights[i]);
|
|
54
|
-
if (cur[i] > cur[best])
|
|
55
|
-
best = i;
|
|
56
|
-
}
|
|
57
|
-
cur[best] -= total;
|
|
58
|
-
return best;
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
/** Exact hostname or `*.suffix` wildcard (the bare suffix also matches). */
|
|
62
|
-
function hostMatches(pattern, host) {
|
|
63
|
-
return pattern.startsWith('*.')
|
|
64
|
-
? host.endsWith(pattern.slice(1)) || host === pattern.slice(2)
|
|
65
|
-
: host === pattern;
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* THE matcher — shared by dev and prod. Generic over the route shape so the
|
|
69
|
-
* dev gateway can pass its pod-rich routes and prod its upstream routes; only
|
|
70
|
-
* `host`/`prefix` are read here.
|
|
71
|
-
*
|
|
72
|
-
* Rules (each one a fixed bug):
|
|
73
|
-
* - A host CLAIMED by any declaration never falls through to the catch-all
|
|
74
|
-
* default; its unmatched paths belong to the gateway's own infra.
|
|
75
|
-
* - An UNCLAIMED host may only reach the host-less `default` route — never a
|
|
76
|
-
* host route by accident of sort order (unknown host → default or 404).
|
|
77
|
-
* - An EXACT host match beats a wildcard (`internal.api.x` is served by its
|
|
78
|
-
* own route, not a co-existing `*.x` app).
|
|
79
|
-
* - Within the candidates, the most specific path prefix wins (callers sort
|
|
80
|
-
* routes by specificity: host+path before host before catch-all).
|
|
81
|
-
*/
|
|
82
|
-
function matchRoute(routes, host, url) {
|
|
83
|
-
const claimed = routes.some((r) => r.host && hostMatches(r.host, host));
|
|
84
|
-
let candidates;
|
|
85
|
-
if (claimed) {
|
|
86
|
-
candidates = routes.filter((r) => r.host && hostMatches(r.host, host));
|
|
87
|
-
if (candidates.some((r) => r.host === host)) {
|
|
88
|
-
candidates = candidates.filter((r) => r.host === host);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
else {
|
|
92
|
-
// UNCLAIMED host → the host-less default catch-all(s). ONE default (a single workspace) → today's
|
|
93
|
-
// behavior, apex ignored. MULTIPLE defaults (a shared multi-workspace gateway that merged several
|
|
94
|
-
// workspaces' fragments) → route by APEX: the default whose apex the request host belongs to, longest
|
|
95
|
-
// apex first; a foreign host matching no apex falls to an apex-less GLOBAL default (else nothing → 404).
|
|
96
|
-
const defaults = routes.filter((r) => !r.host);
|
|
97
|
-
if (defaults.length <= 1) {
|
|
98
|
-
candidates = defaults;
|
|
99
|
-
}
|
|
100
|
-
else {
|
|
101
|
-
const apexHit = defaults
|
|
102
|
-
.filter((r) => r.apex && (host === r.apex || host.endsWith('.' + r.apex)))
|
|
103
|
-
.sort((a, b) => b.apex.length - a.apex.length);
|
|
104
|
-
candidates = apexHit.length > 0 ? apexHit : defaults.filter((r) => !r.apex);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return candidates.find((r) => !r.prefix || url.startsWith(r.prefix));
|
|
108
|
-
}
|
|
109
|
-
/** Specificity sort: host+path beats host-only beats catch-all. Mutates+returns. */
|
|
110
|
-
function sortBySpecificity(routes) {
|
|
111
|
-
routes.sort((a, b) => ((b.host ? 2 : 0) + (b.prefix ? 1 : 0)) - ((a.host ? 2 : 0) + (a.prefix ? 1 : 0)));
|
|
112
|
-
return routes;
|
|
113
|
-
}
|
|
114
|
-
/** Resolve the auth mode for a concrete request path against a manifest. */
|
|
115
|
-
function authForPath(manifest, path, internal) {
|
|
116
|
-
if (internal)
|
|
117
|
-
return 'internal';
|
|
118
|
-
const overrides = manifest.auth?.overrides ?? [];
|
|
119
|
-
for (const o of overrides)
|
|
120
|
-
if (path.startsWith(o.path))
|
|
121
|
-
return o.mode;
|
|
122
|
-
return manifest.auth?.default ?? 'jwt';
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* Resolve the auth mode + any edge ABAC rule for a concrete (path, method) against an
|
|
126
|
-
* {@link AuthPolicy} — the shared core both gateways use. First override whose `path` is a
|
|
127
|
-
* prefix and whose `methods` include the method wins (callers emit most-specific-first);
|
|
128
|
-
* `permission` is compiled to a `perms contains` condition.
|
|
129
|
-
*/
|
|
130
|
-
/**
|
|
131
|
-
* Segment-prefix match where a pattern segment of `*` or `:param` matches ANY one path segment —
|
|
132
|
-
* so a route override like `/engage/tenants/:companyId/campaigns` matches the real request
|
|
133
|
-
* `/engage/tenants/abc-123/campaigns` (and anything deeper). Stricter than `startsWith` (it won't
|
|
134
|
-
* cross a segment boundary), which is also more correct. Lets `@RequirePermission` enforce on
|
|
135
|
-
* tenant-scoped (`:param`) routes, not just static prefixes.
|
|
136
|
-
*/
|
|
137
|
-
function authPathMatches(pattern, path) {
|
|
138
|
-
const ps = pattern.split('/').filter(Boolean);
|
|
139
|
-
const xs = (path.split('?')[0] ?? '').split('/').filter(Boolean);
|
|
140
|
-
if (xs.length < ps.length)
|
|
141
|
-
return false;
|
|
142
|
-
for (let i = 0; i < ps.length; i++) {
|
|
143
|
-
const seg = ps[i];
|
|
144
|
-
if (seg === '*' || seg.startsWith(':'))
|
|
145
|
-
continue;
|
|
146
|
-
if (seg !== xs[i])
|
|
147
|
-
return false;
|
|
148
|
-
}
|
|
149
|
-
return true;
|
|
150
|
-
}
|
|
151
|
-
function authRuleFor(policy, path, method) {
|
|
152
|
-
const m = method.toUpperCase();
|
|
153
|
-
for (const o of policy?.overrides ?? []) {
|
|
154
|
-
if (!authPathMatches(o.path, path))
|
|
155
|
-
continue;
|
|
156
|
-
if (o.methods && !o.methods.some((x) => x.toUpperCase() === m))
|
|
157
|
-
continue;
|
|
158
|
-
const rule = o.rule ?? (o.permission ? { attr: 'perms', op: 'hastoken', value: o.permission } : undefined);
|
|
159
|
-
// An override that omits `mode` INHERITS the service default — otherwise a
|
|
160
|
-
// permission-only override would resolve to mode=undefined and the gateway
|
|
161
|
-
// would skip the jwt branch entirely, silently NOT enforcing the permission.
|
|
162
|
-
return { mode: o.mode ?? policy?.default ?? 'jwt', ...(rule ? { rule } : {}) };
|
|
163
|
-
}
|
|
164
|
-
return { mode: policy?.default ?? 'jwt' };
|
|
165
|
-
}
|
|
166
|
-
/**
|
|
167
|
-
* Resolve the auth mode AND any edge ABAC rule for a concrete (path, method) on a manifest —
|
|
168
|
-
* the permission-aware companion to {@link authForPath}. Internal routes are always `internal`;
|
|
169
|
-
* otherwise delegates to {@link authRuleFor}. The gateway verifies the JWT for `jwt` mode and,
|
|
170
|
-
* when a `rule` is present, requires `evalRule(rule, attrs)` (else 403).
|
|
171
|
-
*/
|
|
172
|
-
function authRuleForPath(manifest, path, method, internal) {
|
|
173
|
-
if (internal)
|
|
174
|
-
return { mode: 'internal' };
|
|
175
|
-
return authRuleFor(manifest.auth, path, method);
|
|
176
|
-
}
|
|
177
|
-
/**
|
|
178
|
-
* Build the flat route table from manifests. Each mount becomes a host route;
|
|
179
|
-
* a service flagged `internal` also gets an `internal.<host>` route per mount;
|
|
180
|
-
* a `default` service contributes the host-less catch-all. Sorted by
|
|
181
|
-
* specificity so `matchRoute` picks the most specific.
|
|
182
|
-
*/
|
|
183
|
-
function buildRouteTable(manifests) {
|
|
184
|
-
const routes = [];
|
|
185
|
-
for (const m of manifests) {
|
|
186
|
-
const baseAuth = m.auth?.default ?? 'jwt';
|
|
187
|
-
for (const mount of m.mounts) {
|
|
188
|
-
const base = {
|
|
189
|
-
service: m.service,
|
|
190
|
-
...(mount.path ? { prefix: mount.path } : {}),
|
|
191
|
-
...(m.prependPrefix ? { prependPrefix: m.prependPrefix } : {}),
|
|
192
|
-
...(m.upstream ? { upstream: m.upstream } : {}),
|
|
193
|
-
};
|
|
194
|
-
routes.push({ ...base, host: mount.host, auth: baseAuth });
|
|
195
|
-
if (m.internal && m.internalHost !== false) {
|
|
196
|
-
routes.push({
|
|
197
|
-
...base,
|
|
198
|
-
host: typeof m.internalHost === 'string' ? m.internalHost : `internal.${mount.host}`,
|
|
199
|
-
internal: true,
|
|
200
|
-
auth: 'internal',
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
if (m.default) {
|
|
205
|
-
routes.push({
|
|
206
|
-
service: m.service,
|
|
207
|
-
...(m.prependPrefix ? { prependPrefix: m.prependPrefix } : {}),
|
|
208
|
-
...(m.upstream ? { upstream: m.upstream } : {}),
|
|
209
|
-
default: true,
|
|
210
|
-
auth: baseAuth,
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
return sortBySpecificity(routes);
|
|
215
|
-
}
|
|
216
|
-
/** Synthesize a manifest from a dev project.json `cluster` declaration —
|
|
217
|
-
* the dev ManifestProvider's adapter, and the seed for `lensmcp`-generated
|
|
218
|
-
* prod manifests (same fields, prod adds version/rateLimits). */
|
|
219
|
-
function manifestFromClusterDecl(project, decl) {
|
|
220
|
-
const upstream = decl.upstream ?? (decl.port ? `http://localhost:${decl.port}` : undefined);
|
|
221
|
-
// A service must route SOMEWHERE: either a pod service name or a TCP upstream.
|
|
222
|
-
if (!decl.service && !upstream)
|
|
223
|
-
return undefined;
|
|
224
|
-
return {
|
|
225
|
-
service: decl.service ?? project,
|
|
226
|
-
mounts: decl.host ? [{ host: decl.host, ...(decl.path ? { path: decl.path } : {}) }] : [],
|
|
227
|
-
// pod services get an internal east-west host by default; TCP upstreams don't.
|
|
228
|
-
internal: !!decl.service,
|
|
229
|
-
...(decl.internalHost !== undefined ? { internalHost: decl.internalHost } : {}),
|
|
230
|
-
...(decl.default ? { default: true } : {}),
|
|
231
|
-
...(decl.prependPrefix ? { prependPrefix: decl.prependPrefix } : {}),
|
|
232
|
-
...(decl.auth ? { auth: decl.auth } : {}),
|
|
233
|
-
...(upstream ? { upstream } : {}),
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
const isGroup = (n) => 'all' in n || 'any' in n || 'not' in n;
|
|
237
|
-
const reCache = new Map();
|
|
238
|
-
const compileRe = (src) => {
|
|
239
|
-
if (src.length > 512)
|
|
240
|
-
return null; // ReDoS guard — bound pattern length
|
|
241
|
-
if (reCache.has(src))
|
|
242
|
-
return reCache.get(src);
|
|
243
|
-
let re;
|
|
244
|
-
try {
|
|
245
|
-
re = new RegExp(src);
|
|
246
|
-
}
|
|
247
|
-
catch {
|
|
248
|
-
re = null;
|
|
249
|
-
}
|
|
250
|
-
reCache.set(src, re);
|
|
251
|
-
return re;
|
|
252
|
-
};
|
|
253
|
-
/** IPv4 dotted-quad → uint32, or null if malformed. */
|
|
254
|
-
function ipToInt(ip) {
|
|
255
|
-
const v4 = ip.startsWith('::ffff:') ? ip.slice(7) : ip; // IPv4-mapped IPv6
|
|
256
|
-
const parts = v4.split('.');
|
|
257
|
-
if (parts.length !== 4)
|
|
258
|
-
return null;
|
|
259
|
-
let n = 0;
|
|
260
|
-
for (const p of parts) {
|
|
261
|
-
const o = Number(p);
|
|
262
|
-
if (!Number.isInteger(o) || o < 0 || o > 255 || (p.length > 1 && p[0] === '0'))
|
|
263
|
-
return null;
|
|
264
|
-
n = n * 256 + o;
|
|
265
|
-
}
|
|
266
|
-
return n >>> 0;
|
|
267
|
-
}
|
|
268
|
-
/** `ip ∈ cidr` for IPv4 (handles IPv4-mapped IPv6). False for IPv6/malformed. */
|
|
269
|
-
function matchCidr(ip, cidr) {
|
|
270
|
-
const slash = cidr.indexOf('/');
|
|
271
|
-
if (slash < 0)
|
|
272
|
-
return false;
|
|
273
|
-
const bits = Number(cidr.slice(slash + 1));
|
|
274
|
-
if (!Number.isInteger(bits) || bits < 0 || bits > 32)
|
|
275
|
-
return false;
|
|
276
|
-
const ipN = ipToInt(ip);
|
|
277
|
-
const rangeN = ipToInt(cidr.slice(0, slash));
|
|
278
|
-
if (ipN === null || rangeN === null)
|
|
279
|
-
return false;
|
|
280
|
-
if (bits === 0)
|
|
281
|
-
return true;
|
|
282
|
-
const mask = (bits === 32 ? 0xffffffff : ~((1 << (32 - bits)) - 1)) >>> 0;
|
|
283
|
-
return ((ipN & mask) >>> 0) === ((rangeN & mask) >>> 0);
|
|
284
|
-
}
|
|
285
|
-
const str = (v) => (v === null || v === undefined ? '' : String(v));
|
|
286
|
-
const eq = (a, b) => str(a) === str(b);
|
|
287
|
-
/** Evaluate one condition against the attributes. Undefined attribute → false
|
|
288
|
-
* for every op except `ne` (absent ≠ value → true) and `exists` (→ false). */
|
|
289
|
-
function evalCondition(c, attrs) {
|
|
290
|
-
const v = attrs[c.attr];
|
|
291
|
-
const present = v !== undefined && v !== null;
|
|
292
|
-
switch (c.op) {
|
|
293
|
-
case 'exists': return present;
|
|
294
|
-
case 'ne': return !present || !eq(v, c.value);
|
|
295
|
-
}
|
|
296
|
-
if (!present)
|
|
297
|
-
return false;
|
|
298
|
-
switch (c.op) {
|
|
299
|
-
case 'eq': return eq(v, c.value);
|
|
300
|
-
case 'in': return Array.isArray(c.value) && c.value.some((x) => Array.isArray(v) ? v.some((vv) => eq(vv, x)) : eq(v, x));
|
|
301
|
-
case 'nin': return !(Array.isArray(c.value) && c.value.some((x) => Array.isArray(v) ? v.some((vv) => eq(vv, x)) : eq(v, x)));
|
|
302
|
-
case 'prefix': return str(v).startsWith(str(c.value));
|
|
303
|
-
case 'suffix': return str(v).endsWith(str(c.value));
|
|
304
|
-
case 'contains': return Array.isArray(v) ? v.some((vv) => eq(vv, c.value)) : str(v).includes(str(c.value)); // substring / array element (general op)
|
|
305
|
-
// EXACT token in a list — a `perms`/scope STRING is delimited, so "superadmin"/"billing,adminx"
|
|
306
|
-
// must NOT satisfy hastoken:"admin". This is what `permission` compiles to (authz, no substring bypass).
|
|
307
|
-
case 'hastoken': {
|
|
308
|
-
const cv = str(c.value);
|
|
309
|
-
return Array.isArray(v) ? v.some((vv) => eq(vv, c.value)) : str(v).split(/[\s,]+/).filter(Boolean).includes(cv);
|
|
310
|
-
}
|
|
311
|
-
case 'regex': {
|
|
312
|
-
const re = compileRe(str(c.value));
|
|
313
|
-
return re ? re.test(str(v)) : false;
|
|
314
|
-
}
|
|
315
|
-
case 'gt': return Number(v) > Number(c.value);
|
|
316
|
-
case 'lt': return Number(v) < Number(c.value);
|
|
317
|
-
case 'cidr': return matchCidr(str(v), str(c.value));
|
|
318
|
-
default: return false;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
/** Evaluate a rule node (condition or all/any/not group) against attributes. Pure. */
|
|
322
|
-
function evalRule(node, attrs) {
|
|
323
|
-
if (!isGroup(node))
|
|
324
|
-
return evalCondition(node, attrs);
|
|
325
|
-
// Only MEANINGFUL clauses count — an empty all:[]/any:[]/not:{} must NOT vacuously admit (fail closed),
|
|
326
|
-
// while a real `not:{cond}`-only rule still grants when its veto passes.
|
|
327
|
-
const all = Array.isArray(node.all) && node.all.length ? node.all : undefined;
|
|
328
|
-
const any = Array.isArray(node.any) && node.any.length ? node.any : undefined;
|
|
329
|
-
const not = node.not && Object.keys(node.not).length ? node.not : undefined;
|
|
330
|
-
if (not && evalRule(not, attrs))
|
|
331
|
-
return false; // veto
|
|
332
|
-
if (all && !all.every((n) => evalRule(n, attrs)))
|
|
333
|
-
return false;
|
|
334
|
-
if (any && !any.some((n) => evalRule(n, attrs)))
|
|
335
|
-
return false;
|
|
336
|
-
return !!(all || any || not); // grant only if ≥1 meaningful clause was present (and satisfied)
|
|
337
|
-
}
|
|
338
|
-
/** First matching rule's version (top-to-bottom), or undefined. */
|
|
339
|
-
function pickTargetedVersion(rules, attrs) {
|
|
340
|
-
for (const r of rules) {
|
|
341
|
-
if (evalRule(r.when, attrs))
|
|
342
|
-
return { version: r.version, ...(r.name ? { rule: r.name } : {}) };
|
|
343
|
-
}
|
|
344
|
-
return undefined;
|
|
345
|
-
}
|
|
346
|
-
/** 32-bit FNV-1a hash → unsigned int. Stable across processes (no seed). */
|
|
347
|
-
function fnv1a(s) {
|
|
348
|
-
let h = 0x811c9dc5;
|
|
349
|
-
for (let i = 0; i < s.length; i++) {
|
|
350
|
-
h ^= s.charCodeAt(i);
|
|
351
|
-
h = Math.imul(h, 0x01000193);
|
|
352
|
-
}
|
|
353
|
-
return h >>> 0;
|
|
354
|
-
}
|
|
355
|
-
/**
|
|
356
|
-
* Consistent-hash a stable key to a cohort INDEX by weight. The hash point uses
|
|
357
|
-
* a FIXED modulus (10000) so it is stable per key regardless of the weights;
|
|
358
|
-
* only the cohort thresholds move when weights change — so ramping a weight
|
|
359
|
-
* moves a key across the boundary AT MOST ONCE (no reshuffle/flapping). Fixed
|
|
360
|
-
* cohort order. All-zero weights → 0.
|
|
361
|
-
*/
|
|
362
|
-
function bucketByWeight(key, weights) {
|
|
363
|
-
const total = weights.reduce((a, w) => a + Math.max(0, w), 0);
|
|
364
|
-
if (total <= 0 || weights.length === 0)
|
|
365
|
-
return 0;
|
|
366
|
-
const point = fnv1a(key) % 10000;
|
|
367
|
-
let acc = 0;
|
|
368
|
-
for (let i = 0; i < weights.length; i++) {
|
|
369
|
-
acc += (Math.max(0, weights[i]) / total) * 10000;
|
|
370
|
-
if (point < acc)
|
|
371
|
-
return i;
|
|
372
|
-
}
|
|
373
|
-
return weights.length - 1;
|
|
374
|
-
}
|
|
1
|
+
"use strict";var h=Object.defineProperty;var u=(t,e)=>h(t,"name",{value:e,configurable:!0});var f=Object.defineProperty,a=u((t,e)=>f(t,"name",{value:e,configurable:!0}),"a");Object.defineProperty(exports,"__esModule",{value:!0}),exports.smoothWeightedSelector=smoothWeightedSelector,exports.hostMatches=hostMatches,exports.matchRoute=matchRoute,exports.sortBySpecificity=sortBySpecificity,exports.authForPath=authForPath,exports.authPathMatches=authPathMatches,exports.authRuleFor=authRuleFor,exports.authRuleForPath=authRuleForPath,exports.buildRouteTable=buildRouteTable,exports.manifestFromClusterDecl=manifestFromClusterDecl,exports.matchCidr=matchCidr,exports.evalRule=evalRule,exports.pickTargetedVersion=pickTargetedVersion,exports.fnv1a=fnv1a,exports.bucketByWeight=bucketByWeight;function smoothWeightedSelector(t){const e=t.map(()=>0),r=t.reduce((o,n)=>o+Math.max(0,n),0);return()=>{if(r<=0||t.length===0)return 0;let o=0;for(let n=0;n<t.length;n++)e[n]+=Math.max(0,t[n]),e[n]>e[o]&&(o=n);return e[o]-=r,o}}u(smoothWeightedSelector,"smoothWeightedSelector"),a(smoothWeightedSelector,"smoothWeightedSelector");function hostMatches(t,e){return t.startsWith("*.")?e.endsWith(t.slice(1))||e===t.slice(2):e===t}u(hostMatches,"hostMatches"),a(hostMatches,"hostMatches");function matchRoute(t,e,r){const o=t.some(s=>s.host&&hostMatches(s.host,e));let n;if(o)n=t.filter(s=>s.host&&hostMatches(s.host,e)),n.some(s=>s.host===e)&&(n=n.filter(s=>s.host===e));else{const s=t.filter(i=>!i.host);if(s.length<=1)n=s;else{const i=s.filter(l=>l.apex&&(e===l.apex||e.endsWith("."+l.apex))).sort((l,c)=>c.apex.length-l.apex.length);n=i.length>0?i:s.filter(l=>!l.apex)}}return n.find(s=>!s.prefix||r.startsWith(s.prefix))}u(matchRoute,"matchRoute"),a(matchRoute,"matchRoute");function sortBySpecificity(t){return t.sort((e,r)=>(r.host?2:0)+(r.prefix?1:0)-((e.host?2:0)+(e.prefix?1:0))),t}u(sortBySpecificity,"sortBySpecificity"),a(sortBySpecificity,"sortBySpecificity");function authForPath(t,e,r){if(r)return"internal";const o=t.auth?.overrides??[];for(const n of o)if(e.startsWith(n.path))return n.mode;return t.auth?.default??"jwt"}u(authForPath,"authForPath"),a(authForPath,"authForPath");function authPathMatches(t,e){const r=t.split("/").filter(Boolean),o=(e.split("?")[0]??"").split("/").filter(Boolean);if(o.length<r.length)return!1;for(let n=0;n<r.length;n++){const s=r[n];if(!(s==="*"||s.startsWith(":"))&&s!==o[n])return!1}return!0}u(authPathMatches,"authPathMatches"),a(authPathMatches,"authPathMatches");function authRuleFor(t,e,r){const o=r.toUpperCase();for(const n of t?.overrides??[]){if(!authPathMatches(n.path,e)||n.methods&&!n.methods.some(i=>i.toUpperCase()===o))continue;const s=n.rule??(n.permission?{attr:"perms",op:"hastoken",value:n.permission}:void 0);return{mode:n.mode??t?.default??"jwt",...s?{rule:s}:{}}}return{mode:t?.default??"jwt"}}u(authRuleFor,"authRuleFor"),a(authRuleFor,"authRuleFor");function authRuleForPath(t,e,r,o){return o?{mode:"internal"}:authRuleFor(t.auth,e,r)}u(authRuleForPath,"authRuleForPath"),a(authRuleForPath,"authRuleForPath");function buildRouteTable(t){const e=[];for(const r of t){const o=r.auth?.default??"jwt";for(const n of r.mounts){const s={service:r.service,...n.path?{prefix:n.path}:{},...r.prependPrefix?{prependPrefix:r.prependPrefix}:{},...r.upstream?{upstream:r.upstream}:{}};e.push({...s,host:n.host,auth:o}),r.internal&&r.internalHost!==!1&&e.push({...s,host:typeof r.internalHost=="string"?r.internalHost:`internal.${n.host}`,internal:!0,auth:"internal"})}r.default&&e.push({service:r.service,...r.prependPrefix?{prependPrefix:r.prependPrefix}:{},...r.upstream?{upstream:r.upstream}:{},default:!0,auth:o})}return sortBySpecificity(e)}u(buildRouteTable,"buildRouteTable"),a(buildRouteTable,"buildRouteTable");function manifestFromClusterDecl(t,e){const r=e.upstream??(e.port?`http://localhost:${e.port}`:void 0);if(!(!e.service&&!r))return{service:e.service??t,mounts:e.host?[{host:e.host,...e.path?{path:e.path}:{}}]:[],internal:!!e.service,...e.internalHost!==void 0?{internalHost:e.internalHost}:{},...e.default?{default:!0}:{},...e.prependPrefix?{prependPrefix:e.prependPrefix}:{},...e.auth?{auth:e.auth}:{},...r?{upstream:r}:{}}}u(manifestFromClusterDecl,"manifestFromClusterDecl"),a(manifestFromClusterDecl,"manifestFromClusterDecl");const isGroup=a(t=>"all"in t||"any"in t||"not"in t,"isGroup"),reCache=new Map,compileRe=a(t=>{if(t.length>512)return null;if(reCache.has(t))return reCache.get(t);let e;try{e=new RegExp(t)}catch{e=null}return reCache.set(t,e),e},"compileRe");function ipToInt(t){const e=(t.startsWith("::ffff:")?t.slice(7):t).split(".");if(e.length!==4)return null;let r=0;for(const o of e){const n=Number(o);if(!Number.isInteger(n)||n<0||n>255||o.length>1&&o[0]==="0")return null;r=r*256+n}return r>>>0}u(ipToInt,"ipToInt"),a(ipToInt,"ipToInt");function matchCidr(t,e){const r=e.indexOf("/");if(r<0)return!1;const o=Number(e.slice(r+1));if(!Number.isInteger(o)||o<0||o>32)return!1;const n=ipToInt(t),s=ipToInt(e.slice(0,r));if(n===null||s===null)return!1;if(o===0)return!0;const i=(o===32?4294967295:~((1<<32-o)-1))>>>0;return(n&i)>>>0===(s&i)>>>0}u(matchCidr,"matchCidr"),a(matchCidr,"matchCidr");const str=a(t=>t==null?"":String(t),"str"),eq=a((t,e)=>str(t)===str(e),"eq");function evalCondition(t,e){const r=e[t.attr],o=r!=null;switch(t.op){case"exists":return o;case"ne":return!o||!eq(r,t.value)}if(!o)return!1;switch(t.op){case"eq":return eq(r,t.value);case"in":return Array.isArray(t.value)&&t.value.some(n=>Array.isArray(r)?r.some(s=>eq(s,n)):eq(r,n));case"nin":return!(Array.isArray(t.value)&&t.value.some(n=>Array.isArray(r)?r.some(s=>eq(s,n)):eq(r,n)));case"prefix":return str(r).startsWith(str(t.value));case"suffix":return str(r).endsWith(str(t.value));case"contains":return Array.isArray(r)?r.some(n=>eq(n,t.value)):str(r).includes(str(t.value));case"hastoken":{const n=str(t.value);return Array.isArray(r)?r.some(s=>eq(s,t.value)):str(r).split(/[\s,]+/).filter(Boolean).includes(n)}case"regex":{const n=compileRe(str(t.value));return n?n.test(str(r)):!1}case"gt":return Number(r)>Number(t.value);case"lt":return Number(r)<Number(t.value);case"cidr":return matchCidr(str(r),str(t.value));default:return!1}}u(evalCondition,"evalCondition"),a(evalCondition,"evalCondition");function evalRule(t,e){if(!isGroup(t))return evalCondition(t,e);const r=Array.isArray(t.all)&&t.all.length?t.all:void 0,o=Array.isArray(t.any)&&t.any.length?t.any:void 0,n=t.not&&Object.keys(t.not).length?t.not:void 0;return n&&evalRule(n,e)||r&&!r.every(s=>evalRule(s,e))||o&&!o.some(s=>evalRule(s,e))?!1:!!(r||o||n)}u(evalRule,"evalRule"),a(evalRule,"evalRule");function pickTargetedVersion(t,e){for(const r of t)if(evalRule(r.when,e))return{version:r.version,...r.name?{rule:r.name}:{}}}u(pickTargetedVersion,"pickTargetedVersion"),a(pickTargetedVersion,"pickTargetedVersion");function fnv1a(t){let e=2166136261;for(let r=0;r<t.length;r++)e^=t.charCodeAt(r),e=Math.imul(e,16777619);return e>>>0}u(fnv1a,"fnv1a"),a(fnv1a,"fnv1a");function bucketByWeight(t,e){const r=e.reduce((s,i)=>s+Math.max(0,i),0);if(r<=0||e.length===0)return 0;const o=fnv1a(t)%1e4;let n=0;for(let s=0;s<e.length;s++)if(n+=Math.max(0,e[s])/r*1e4,o<n)return s;return e.length-1}u(bucketByWeight,"bucketByWeight"),a(bucketByWeight,"bucketByWeight");
|
|
@@ -1,56 +1 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.createProcessMetrics = createProcessMetrics;
|
|
4
|
-
/**
|
|
5
|
-
* Process self-metrics for the production gateway — the signals a load/stress test
|
|
6
|
-
* needs that can ONLY be measured from inside the process: event-loop delay (lag),
|
|
7
|
-
* RSS/heap, CPU time, and request rates. OPT-IN; exposed via the token-gated
|
|
8
|
-
* `/metricsz` endpoint and reusable for production monitoring (Prometheus-style scrape).
|
|
9
|
-
*
|
|
10
|
-
* `snapshot()` returns DELTAS since the previous call (CPU%, requests/sec, and a fresh
|
|
11
|
-
* event-loop-lag histogram), so a poller sampling every N seconds reads interval rates —
|
|
12
|
-
* the natural shape for a load run's time series.
|
|
13
|
-
*/
|
|
14
|
-
const node_perf_hooks_1 = require("node:perf_hooks");
|
|
15
|
-
const MB = 1024 * 1024;
|
|
16
|
-
const round = (n, d = 1) => { const f = 10 ** d; return Math.round(n * f) / f; };
|
|
17
|
-
function createProcessMetrics() {
|
|
18
|
-
const startedAt = Date.now();
|
|
19
|
-
const loop = (0, node_perf_hooks_1.monitorEventLoopDelay)({ resolution: 10 });
|
|
20
|
-
loop.enable();
|
|
21
|
-
let lastCpu = process.cpuUsage();
|
|
22
|
-
let lastAt = Date.now();
|
|
23
|
-
let total = 0, s2 = 0, s4 = 0, s5 = 0, lastTotal = 0;
|
|
24
|
-
const lagMs = (ns) => round(ns / 1e6, 2);
|
|
25
|
-
return {
|
|
26
|
-
recordRequest(status) {
|
|
27
|
-
total += 1;
|
|
28
|
-
if (status >= 500 || status === 0)
|
|
29
|
-
s5 += 1;
|
|
30
|
-
else if (status >= 400)
|
|
31
|
-
s4 += 1;
|
|
32
|
-
else if (status >= 200)
|
|
33
|
-
s2 += 1;
|
|
34
|
-
},
|
|
35
|
-
snapshot() {
|
|
36
|
-
const now = Date.now();
|
|
37
|
-
const wallMs = Math.max(1, now - lastAt);
|
|
38
|
-
const cpu = process.cpuUsage(lastCpu); // µs since lastCpu
|
|
39
|
-
const mem = process.memoryUsage();
|
|
40
|
-
const cpuMs = (cpu.user + cpu.system) / 1000;
|
|
41
|
-
const snap = {
|
|
42
|
-
uptimeSec: Math.round((now - startedAt) / 1000),
|
|
43
|
-
rssMB: round(mem.rss / MB), heapUsedMB: round(mem.heapUsed / MB), heapTotalMB: round(mem.heapTotal / MB), externalMB: round(mem.external / MB),
|
|
44
|
-
cpu: { userMs: round(cpu.user / 1000), systemMs: round(cpu.system / 1000), percent: round((cpuMs / wallMs) * 100) },
|
|
45
|
-
loopLagMs: { mean: lagMs(loop.mean), p50: lagMs(loop.percentile(50)), p90: lagMs(loop.percentile(90)), p99: lagMs(loop.percentile(99)), max: lagMs(loop.max) },
|
|
46
|
-
requests: { total, sinceLast: total - lastTotal, perSec: Math.round(((total - lastTotal) / wallMs) * 1000), s2xx: s2, s4xx: s4, s5xx: s5 },
|
|
47
|
-
};
|
|
48
|
-
lastCpu = process.cpuUsage();
|
|
49
|
-
lastAt = now;
|
|
50
|
-
lastTotal = total;
|
|
51
|
-
loop.reset();
|
|
52
|
-
return snap;
|
|
53
|
-
},
|
|
54
|
-
stop() { loop.disable(); },
|
|
55
|
-
};
|
|
56
|
-
}
|
|
1
|
+
"use strict";var y=Object.defineProperty;var l=(r,e)=>y(r,"name",{value:e,configurable:!0});var f=Object.defineProperty,p=l((r,e)=>f(r,"name",{value:e,configurable:!0}),"p");Object.defineProperty(exports,"__esModule",{value:!0}),exports.createProcessMetrics=createProcessMetrics;const node_perf_hooks_1=require("node:perf_hooks"),MB=1024*1024,round=p((r,e=1)=>{const t=10**e;return Math.round(r*t)/t},"round");function createProcessMetrics(){const r=Date.now(),e=(0,node_perf_hooks_1.monitorEventLoopDelay)({resolution:10});e.enable();let t=process.cpuUsage(),i=Date.now(),o=0,M=0,d=0,m=0,u=0;const a=p(s=>round(s/1e6,2),"lagMs");return{recordRequest(s){o+=1,s>=500||s===0?m+=1:s>=400?d+=1:s>=200&&(M+=1)},snapshot(){const s=Date.now(),x=Math.max(1,s-i),n=process.cpuUsage(t),c=process.memoryUsage(),h=(n.user+n.system)/1e3,g={uptimeSec:Math.round((s-r)/1e3),rssMB:round(c.rss/MB),heapUsedMB:round(c.heapUsed/MB),heapTotalMB:round(c.heapTotal/MB),externalMB:round(c.external/MB),cpu:{userMs:round(n.user/1e3),systemMs:round(n.system/1e3),percent:round(h/x*100)},loopLagMs:{mean:a(e.mean),p50:a(e.percentile(50)),p90:a(e.percentile(90)),p99:a(e.percentile(99)),max:a(e.max)},requests:{total:o,sinceLast:o-u,perSec:Math.round((o-u)/x*1e3),s2xx:M,s4xx:d,s5xx:m}};return t=process.cpuUsage(),i=s,u=o,e.reset(),g},stop(){e.disable()}}}l(createProcessMetrics,"createProcessMetrics"),p(createProcessMetrics,"createProcessMetrics");
|
|
@@ -1,74 +1 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.setupTracing = setupTracing;
|
|
4
|
-
/**
|
|
5
|
-
* OpenTelemetry tracing for the production gateway — OPT-IN, cloud-neutral.
|
|
6
|
-
*
|
|
7
|
-
* Makes the gateway a first-class span in a distributed trace: it continues the
|
|
8
|
-
* incoming W3C `traceparent`, emits one SERVER span per request, and INJECTS its
|
|
9
|
-
* context into the forwarded headers so the downstream service's spans parent to
|
|
10
|
-
* the gateway hop. Spans export over OTLP/HTTP — point `OTEL_EXPORTER_OTLP_ENDPOINT`
|
|
11
|
-
* at Cloud Trace (GCP), the OTel Collector / X-Ray (AWS), Tempo, Jaeger, etc. The
|
|
12
|
-
* SAME image traces on any cloud; only the endpoint env changes.
|
|
13
|
-
*
|
|
14
|
-
* All OpenTelemetry imports live HERE. The gateway lib takes a `GatewayTracer`
|
|
15
|
-
* (type-only) and calls these helpers, so it never pulls OTel unless tracing is
|
|
16
|
-
* wired — and when no endpoint is set, `setupTracing` returns undefined and the
|
|
17
|
-
* hot path does zero span work.
|
|
18
|
-
*
|
|
19
|
-
* No global context manager (`provider.register()` is intentionally NOT called):
|
|
20
|
-
* context is threaded explicitly via extract/inject, keeping the proxy hot path
|
|
21
|
-
* synchronous and free of async-hooks overhead.
|
|
22
|
-
*/
|
|
23
|
-
const api_1 = require("@opentelemetry/api");
|
|
24
|
-
const core_1 = require("@opentelemetry/core");
|
|
25
|
-
const sdk_trace_base_1 = require("@opentelemetry/sdk-trace-base");
|
|
26
|
-
const resources_1 = require("@opentelemetry/resources");
|
|
27
|
-
const exporter_trace_otlp_http_1 = require("@opentelemetry/exporter-trace-otlp-http");
|
|
28
|
-
const firstHeader = (h, k) => {
|
|
29
|
-
const v = h[k.toLowerCase()];
|
|
30
|
-
return Array.isArray(v) ? v[0] : v;
|
|
31
|
-
};
|
|
32
|
-
const getter = {
|
|
33
|
-
keys: (carrier) => Object.keys(carrier),
|
|
34
|
-
get: (carrier, key) => firstHeader(carrier, key),
|
|
35
|
-
};
|
|
36
|
-
const setter = {
|
|
37
|
-
set: (carrier, key, value) => { carrier[key.toLowerCase()] = value; },
|
|
38
|
-
};
|
|
39
|
-
/** Build the gateway tracer, or undefined when no OTLP endpoint is configured
|
|
40
|
-
* (default-off → the lib skips all span work, zero hot-path overhead). */
|
|
41
|
-
function setupTracing(opts = {}) {
|
|
42
|
-
const endpoint = opts.endpoint ?? process.env['OTEL_EXPORTER_OTLP_ENDPOINT'];
|
|
43
|
-
if (!endpoint)
|
|
44
|
-
return undefined;
|
|
45
|
-
const base = endpoint.replace(/\/+$/, '');
|
|
46
|
-
const url = /\/v1\/traces$/.test(base) ? base : `${base}/v1/traces`;
|
|
47
|
-
const exporter = new exporter_trace_otlp_http_1.OTLPTraceExporter({ url, ...(opts.headers ? { headers: opts.headers } : {}) });
|
|
48
|
-
const provider = new sdk_trace_base_1.BasicTracerProvider({
|
|
49
|
-
resource: (0, resources_1.resourceFromAttributes)({ 'service.name': opts.serviceName ?? process.env['OTEL_SERVICE_NAME'] ?? 'lensmcp-gateway' }),
|
|
50
|
-
sampler: new sdk_trace_base_1.ParentBasedSampler({ root: new sdk_trace_base_1.TraceIdRatioBasedSampler(opts.sampleRatio ?? 1) }),
|
|
51
|
-
// OTel 2.0: span processors are constructor config (the mutating `addSpanProcessor` was removed).
|
|
52
|
-
spanProcessors: [new sdk_trace_base_1.BatchSpanProcessor(exporter)],
|
|
53
|
-
});
|
|
54
|
-
const tracer = provider.getTracer('lensmcp-gateway');
|
|
55
|
-
const propagator = new core_1.W3CTraceContextPropagator();
|
|
56
|
-
return {
|
|
57
|
-
startSpan(req, name, attrs) {
|
|
58
|
-
const parent = propagator.extract(api_1.ROOT_CONTEXT, req.headers, getter);
|
|
59
|
-
return tracer.startSpan(name, { kind: api_1.SpanKind.SERVER, attributes: attrs }, parent);
|
|
60
|
-
},
|
|
61
|
-
inject(span, headers) {
|
|
62
|
-
propagator.inject(api_1.trace.setSpan(api_1.ROOT_CONTEXT, span), headers, setter);
|
|
63
|
-
},
|
|
64
|
-
end(span, statusCode, attrs) {
|
|
65
|
-
if (attrs)
|
|
66
|
-
span.setAttributes(attrs);
|
|
67
|
-
span.setAttribute('http.response.status_code', statusCode);
|
|
68
|
-
if (statusCode >= 500 || statusCode === 0)
|
|
69
|
-
span.setStatus({ code: api_1.SpanStatusCode.ERROR });
|
|
70
|
-
span.end();
|
|
71
|
-
},
|
|
72
|
-
shutdown: () => provider.shutdown(),
|
|
73
|
-
};
|
|
74
|
-
}
|
|
1
|
+
"use strict";var O=Object.defineProperty;var c=(e,r)=>O(e,"name",{value:r,configurable:!0});var T=Object.defineProperty,o=c((e,r)=>T(e,"name",{value:r,configurable:!0}),"o");Object.defineProperty(exports,"__esModule",{value:!0}),exports.setupTracing=setupTracing;const api_1=require("@opentelemetry/api"),core_1=require("@opentelemetry/core"),sdk_trace_base_1=require("@opentelemetry/sdk-trace-base"),resources_1=require("@opentelemetry/resources"),exporter_trace_otlp_http_1=require("@opentelemetry/exporter-trace-otlp-http"),firstHeader=o((e,r)=>{const t=e[r.toLowerCase()];return Array.isArray(t)?t[0]:t},"firstHeader"),getter={keys:o(e=>Object.keys(e),"keys"),get:o((e,r)=>firstHeader(e,r),"get")},setter={set:o((e,r,t)=>{e[r.toLowerCase()]=t},"set")};function setupTracing(e={}){const r=e.endpoint??process.env.OTEL_EXPORTER_OTLP_ENDPOINT;if(!r)return;const t=r.replace(/\/+$/,""),u=/\/v1\/traces$/.test(t)?t:`${t}/v1/traces`,d=new exporter_trace_otlp_http_1.OTLPTraceExporter({url:u,...e.headers?{headers:e.headers}:{}}),p=new sdk_trace_base_1.BasicTracerProvider({resource:(0,resources_1.resourceFromAttributes)({"service.name":e.serviceName??process.env.OTEL_SERVICE_NAME??"lensmcp-gateway"}),sampler:new sdk_trace_base_1.ParentBasedSampler({root:new sdk_trace_base_1.TraceIdRatioBasedSampler(e.sampleRatio??1)}),spanProcessors:[new sdk_trace_base_1.BatchSpanProcessor(d)]}),_=p.getTracer("lensmcp-gateway"),i=new core_1.W3CTraceContextPropagator;return{startSpan(s,a,n){const l=i.extract(api_1.ROOT_CONTEXT,s.headers,getter);return _.startSpan(a,{kind:api_1.SpanKind.SERVER,attributes:n},l)},inject(s,a){i.inject(api_1.trace.setSpan(api_1.ROOT_CONTEXT,s),a,setter)},end(s,a,n){n&&s.setAttributes(n),s.setAttribute("http.response.status_code",a),(a>=500||a===0)&&s.setStatus({code:api_1.SpanStatusCode.ERROR}),s.end()},shutdown:o(()=>p.shutdown(),"shutdown")}}c(setupTracing,"setupTracing"),o(setupTracing,"setupTracing");
|