@lensmcp/cluster 1.0.0 → 1.1.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.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * In-memory token-bucket rate limiter — OPT-IN, per-instance, per-key.
3
+ *
4
+ * Defense-in-depth, NOT the primary limiter: Cloudflare / Cloud Armor remain the
5
+ * global edge throttle. This caps abuse per gateway instance, keyed by the real
6
+ * client IP (resolved through the same trusted-proxy logic as ABAC), and only on
7
+ * the proxied catch-all — the ops endpoints (/livez, /readyz, /statusz) are
8
+ * registered routes and are never subject to it.
9
+ *
10
+ * Token bucket: `limit` tokens refill smoothly over `windowMs`; each request
11
+ * spends one. Empty bucket → denied with the ms until the next token. Idle
12
+ * (refilled-to-full) buckets are swept so the key map can't grow unbounded.
13
+ */
14
+ export interface RateLimiterOptions {
15
+ /** Tokens (requests) per window. */
16
+ limit: number;
17
+ /** Window length the `limit` refills over (ms). */
18
+ windowMs: number;
19
+ /** Injectable clock (tests). Defaults to Date.now. */
20
+ now?: () => number;
21
+ }
22
+ export interface RateDecision {
23
+ allowed: boolean;
24
+ remaining: number;
25
+ retryAfterMs: number;
26
+ }
27
+ export interface RateLimiter {
28
+ /** Spend one token for `key`. In-memory returns synchronously; the Redis-backed
29
+ * limiter returns a Promise (one round-trip) — the gateway `await`s either. */
30
+ check(key: string): RateDecision | Promise<RateDecision>;
31
+ stop(): void;
32
+ }
33
+ export declare function createRateLimiter(opts: RateLimiterOptions): RateLimiter;
34
+ /**
35
+ * Redis-backed token-bucket rate limiter — ONE GLOBAL budget shared by every
36
+ * gateway container/instance (so a per-IP limit is N-container-independent, unlike
37
+ * the in-memory limiter whose limit silently multiplies by instance count).
38
+ *
39
+ * The check is a single atomic Lua EVAL (no GET-then-SET race), using Redis's own
40
+ * clock (`TIME`) so instances agree on refill regardless of host clock skew. The
41
+ * bucket key TTLs out shortly after a full refill, so idle clients leave no residue.
42
+ *
43
+ * Hot-path cost: one Redis round-trip per request. It is bounded by `timeoutMs` and
44
+ * **fails open** by default — a slow/unreachable Redis must not 429 all traffic and
45
+ * take the gateway down (it's defense-in-depth; Cloudflare/Cloud Armor stay the
46
+ * primary edge throttle). Set `failOpen: false` only if you'd rather shed load than
47
+ * risk exceeding the budget during a Redis outage.
48
+ */
49
+ export interface RedisRateLimiterOptions {
50
+ /** Tokens (requests) per window. */
51
+ limit: number;
52
+ /** Window the `limit` refills over (ms). */
53
+ windowMs: number;
54
+ /** Key namespace — the Redis key is `<prefix><key>`. Default 'lensmcp:gw:rl:'. */
55
+ prefix?: string;
56
+ /** Max ms to wait on the Redis round-trip before applying the fail policy. Default 100. */
57
+ timeoutMs?: number;
58
+ /** On Redis error/timeout: true → allow (default), false → deny. */
59
+ failOpen?: boolean;
60
+ }
61
+ /** The single ioredis method the limiter needs — an atomic script eval. */
62
+ export interface RateLimitRedis {
63
+ eval(script: string, numKeys: number, ...args: (string | number)[]): Promise<unknown>;
64
+ }
65
+ export declare function createRedisRateLimiter(client: RateLimitRedis, opts: RedisRateLimiterOptions): RateLimiter;
66
+ //# sourceMappingURL=rate-limit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/rate-limit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,kBAAkB;IACjC,oCAAoC;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,sDAAsD;IACtD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,YAAY;IAAG,OAAO,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE;AAE3F,MAAM,WAAW,WAAW;IAC1B;oFACgF;IAChF,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IACzD,IAAI,IAAI,IAAI,CAAC;CACd;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,kBAAkB,GAAG,WAAW,CA6BvE;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,uBAAuB;IACtC,oCAAoC;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,4CAA4C;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2FAA2F;IAC3F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oEAAoE;IACpE,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,2EAA2E;AAC3E,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACvF;AA2BD,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,cAAc,EAAE,IAAI,EAAE,uBAAuB,GAAG,WAAW,CAgCzG"}
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createRateLimiter = createRateLimiter;
4
+ exports.createRedisRateLimiter = createRedisRateLimiter;
5
+ function createRateLimiter(opts) {
6
+ const limit = Math.max(1, opts.limit);
7
+ const windowMs = Math.max(1, opts.windowMs);
8
+ const now = opts.now ?? Date.now;
9
+ const refillPerMs = limit / windowMs;
10
+ const buckets = new Map();
11
+ const refilled = (b, t) => Math.min(limit, b.tokens + (t - b.last) * refillPerMs);
12
+ // Evict buckets that have refilled to full (idle) so the map stays bounded.
13
+ const sweep = setInterval(() => {
14
+ const t = now();
15
+ for (const [k, b] of buckets)
16
+ if (refilled(b, t) >= limit)
17
+ buckets.delete(k);
18
+ }, windowMs);
19
+ sweep.unref?.();
20
+ return {
21
+ check(key) {
22
+ const t = now();
23
+ let b = buckets.get(key);
24
+ if (!b) {
25
+ b = { tokens: limit, last: t };
26
+ buckets.set(key, b);
27
+ }
28
+ b.tokens = refilled(b, t);
29
+ b.last = t;
30
+ if (b.tokens >= 1) {
31
+ b.tokens -= 1;
32
+ return { allowed: true, remaining: Math.floor(b.tokens), retryAfterMs: 0 };
33
+ }
34
+ return { allowed: false, remaining: 0, retryAfterMs: Math.ceil((1 - b.tokens) / refillPerMs) };
35
+ },
36
+ stop() { clearInterval(sweep); },
37
+ };
38
+ }
39
+ // Atomic token bucket in one round-trip. Stores fractional tokens (Redis keeps the
40
+ // string form) so refill is exact across calls; only the returned remaining/retry
41
+ // are integer-coerced by the Lua→RESP boundary, which is fine.
42
+ const BUCKET_LUA = `
43
+ local b = redis.call('HMGET', KEYS[1], 't', 's')
44
+ local cap = tonumber(ARGV[1])
45
+ local refill = tonumber(ARGV[2])
46
+ local ttl = tonumber(ARGV[3])
47
+ local tm = redis.call('TIME')
48
+ local now = tm[1] * 1000 + tm[2] / 1000
49
+ local tokens = tonumber(b[1])
50
+ local ts = tonumber(b[2])
51
+ if tokens == nil then tokens = cap; ts = now end
52
+ tokens = math.min(cap, tokens + (now - ts) * refill)
53
+ local ok = 0
54
+ if tokens >= 1 then tokens = tokens - 1; ok = 1 end
55
+ redis.call('HSET', KEYS[1], 't', tokens, 's', now)
56
+ redis.call('PEXPIRE', KEYS[1], ttl)
57
+ local retry = 0
58
+ if ok == 0 then retry = math.ceil((1 - tokens) / refill) end
59
+ return {ok, math.floor(tokens), retry}`;
60
+ const RL_TIMEOUT = Symbol('rl-timeout');
61
+ const RL_FAIL = Symbol('rl-fail');
62
+ function createRedisRateLimiter(client, opts) {
63
+ const limit = Math.max(1, opts.limit);
64
+ const windowMs = Math.max(1, opts.windowMs);
65
+ const prefix = opts.prefix ?? 'lensmcp:gw:rl:';
66
+ const timeoutMs = Math.max(1, opts.timeoutMs ?? 100);
67
+ const failOpen = opts.failOpen !== false;
68
+ const refillPerMs = limit / windowMs;
69
+ const ttlMs = Math.ceil(windowMs) + 1000; // evict idle buckets shortly after a full refill
70
+ const onFail = () => failOpen
71
+ ? { allowed: true, remaining: limit, retryAfterMs: 0 }
72
+ : { allowed: false, remaining: 0, retryAfterMs: windowMs };
73
+ return {
74
+ async check(key) {
75
+ let timer;
76
+ // The eval branch is mapped so it can never reject (→ RL_FAIL) — that keeps a
77
+ // late rejection (after a timeout already won the race) from going unhandled.
78
+ const evalP = Promise.resolve(client.eval(BUCKET_LUA, 1, `${prefix}${key}`, limit, refillPerMs, ttlMs)).then((v) => v, () => RL_FAIL);
79
+ const timeoutP = new Promise((res) => { timer = setTimeout(() => res(RL_TIMEOUT), timeoutMs); });
80
+ const res = await Promise.race([evalP, timeoutP]);
81
+ clearTimeout(timer);
82
+ if (res === RL_TIMEOUT || res === RL_FAIL)
83
+ return onFail();
84
+ const [ok, remaining, retry] = res;
85
+ return ok === 1
86
+ ? { allowed: true, remaining: Number(remaining) || 0, retryAfterMs: 0 }
87
+ : { allowed: false, remaining: 0, retryAfterMs: Number(retry) || windowMs };
88
+ },
89
+ stop() { },
90
+ };
91
+ }
package/executors.json CHANGED
@@ -1,23 +1,23 @@
1
1
  {
2
2
  "executors": {
3
3
  "build": {
4
- "implementation": "./src/executors/build/build.impl",
5
- "schema": "./src/executors/build/schema.json",
4
+ "implementation": "./executors/build/build.impl",
5
+ "schema": "./executors/build/schema.json",
6
6
  "description": "Production webpack build for a NestJS application."
7
7
  },
8
8
  "serve": {
9
- "implementation": "./src/executors/serve/serve.impl",
10
- "schema": "./src/executors/serve/schema.json",
9
+ "implementation": "./executors/serve/serve.impl",
10
+ "schema": "./executors/serve/schema.json",
11
11
  "description": "Webpack watch mode with integrated devserver for a NestJS application."
12
12
  },
13
13
  "trust": {
14
- "implementation": "./src/executors/trust/trust.impl",
15
- "schema": "./src/executors/trust/schema.json",
14
+ "implementation": "./executors/trust/trust.impl",
15
+ "schema": "./executors/trust/schema.json",
16
16
  "description": "One-time HTTPS dev setup: trust the davnx local CA + write /etc/hosts entries for gateway hostnames (idempotent, sudo prompts inline)."
17
17
  },
18
18
  "gateway": {
19
- "implementation": "./src/executors/gateway/gateway.impl",
20
- "schema": "./src/executors/gateway/schema.json",
19
+ "implementation": "./executors/gateway/gateway.impl",
20
+ "schema": "./executors/gateway/schema.json",
21
21
  "description": "Workspace https front door: reads every project davnx declaration (host/port/prefix), routes by hostname, serves the local-CA cert with all SANs."
22
22
  }
23
23
  }
package/main.devserver.js CHANGED
@@ -119,7 +119,7 @@ function cleanupSock(sockPath) {
119
119
  try {
120
120
  fs.unlinkSync(sockPath);
121
121
  }
122
- catch { }
122
+ catch { /* best-effort: socket already gone */ }
123
123
  }
124
124
  // delete from CJS cache + require fresh
125
125
  async function importFresh(spec) {
@@ -281,6 +281,7 @@ if (process.env.APP_RUNNER === '1') {
281
281
  inspector?.close?.();
282
282
  }
283
283
  catch {
284
+ /* inspector may already be detached */
284
285
  }
285
286
  });
286
287
  const shutdown = async (sig) => {
@@ -546,7 +547,7 @@ else {
546
547
  try {
547
548
  w.proc.kill('SIGTERM');
548
549
  }
549
- catch { }
550
+ catch { /* worker already exited */ }
550
551
  }
551
552
  workerProcesses.length = 0;
552
553
  for (const name of WORKER_NAMES) {
@@ -789,7 +790,7 @@ else {
789
790
  try {
790
791
  w.proc.kill('SIGTERM');
791
792
  }
792
- catch { }
793
+ catch { /* worker already exited */ }
793
794
  }
794
795
  for (const c of children) {
795
796
  try {
@@ -804,7 +805,7 @@ else {
804
805
  try {
805
806
  fs.rmdirSync(SOCK_DIR);
806
807
  }
807
- catch { }
808
+ catch { /* best-effort: dir missing or non-empty */ }
808
809
  setTimeout(() => process.exit(0), 700).unref();
809
810
  };
810
811
  ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT', 'SIGUSR2'].forEach(sig => process.on(sig, () => void shutdown(sig)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lensmcp/cluster",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Run your Nx workspace as a local production cluster: pods on unix sockets with true HMR, one https gateway with per-project domains, scale-from-zero, autoscale, idle-kill, local-CA TLS — observed by the LensMCP lens.",
5
5
  "main": "./index.js",
6
6
  "types": "./index.d.ts",
@@ -31,7 +31,12 @@
31
31
  "webpack": "^5.0.0",
32
32
  "fastify": "^5.0.0",
33
33
  "@fastify/reply-from": "^12.0.0",
34
- "ioredis": "^5.0.0"
34
+ "ioredis": "^5.0.0",
35
+ "@opentelemetry/api": "^1.9.0",
36
+ "@opentelemetry/core": "^1.30.0",
37
+ "@opentelemetry/resources": "^1.30.0",
38
+ "@opentelemetry/sdk-trace-base": "^1.30.0",
39
+ "@opentelemetry/exporter-trace-otlp-http": "^0.57.0"
35
40
  },
36
41
  "peerDependenciesMeta": {
37
42
  "fastify": {
@@ -42,6 +47,21 @@
42
47
  },
43
48
  "ioredis": {
44
49
  "optional": true
50
+ },
51
+ "@opentelemetry/api": {
52
+ "optional": true
53
+ },
54
+ "@opentelemetry/core": {
55
+ "optional": true
56
+ },
57
+ "@opentelemetry/resources": {
58
+ "optional": true
59
+ },
60
+ "@opentelemetry/sdk-trace-base": {
61
+ "optional": true
62
+ },
63
+ "@opentelemetry/exporter-trace-otlp-http": {
64
+ "optional": true
45
65
  }
46
66
  },
47
67
  "dependencies": {
@@ -53,6 +73,7 @@
53
73
  "node-forge": "^1.3.1",
54
74
  "pino-pretty": "^13.0.0",
55
75
  "terser": "^5.0.0",
76
+ "tslib": "^2.3.0",
56
77
  "webpack-node-externals": "^3.0.0"
57
78
  },
58
79
  "devDependencies": {