@camstack/server 1.2.688 → 1.2.690

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.
@@ -254,7 +254,7 @@ function buildServerProviders(services) {
254
254
  integrations: wrap(() => (0, cap_providers_js_1.buildIntegrationsProvider)(services.addonRegistry, services.eventBus, services.loggingService, services.capabilityRegistry)),
255
255
  nodes: wrap(() => (0, cap_providers_js_1.buildNodesProvider)(services.agentRegistry, services.moleculer, services.addonRegistry, (0, cap_providers_js_1.createNodeRootPackageLookup)(services.moleculer, services.serverUpdateService), services.loggingService.createLogger('nodes'))),
256
256
  // server-management — runtime-updatable root package (phase 1: hub only).
257
- 'server-management': wrap(() => (0, server_management_provider_js_1.buildServerManagementProvider)(services.serverUpdateService)),
257
+ 'server-management': wrap(() => (0, server_management_provider_js_1.buildServerManagementProvider)(services.serverUpdateService, path.resolve(process.env['CAMSTACK_DATA'] ?? 'camstack-data'))),
258
258
  addons: wrap((ctx) => (0, cap_providers_js_1.buildAddonsProvider)(services.addonRegistry, services.addonPackageService, services.loggingService, services.moleculer, services.configService, ctx, services.agentRegistry)),
259
259
  // webrtc-session — the resolved broker singleton, wrapped per request to
260
260
  // inject the server-read User-Agent into signaling attribution (the broker
@@ -418,27 +418,17 @@ class AgentRegistryService {
418
418
  * nothing re-announces it, and the operator has no reason to wait for a
419
419
  * timer they cannot see.
420
420
  *
421
- * An ONLINE node is REFUSED: dropping a live node from the routing catalog
422
- * would break every cap call pinned to it, and it re-registers on the next
423
- * heartbeat anyway — "forget" of a live node is not a slow success, it is
424
- * not a thing.
421
+ * An ONLINE node is forgotten too (operator ruling 2026-10-29, D966). The old refusal said a
422
+ * live node re-registers at once; since D857 its leaf is on the CRL, so it cannot. The order
423
+ * matters: (1) revoke (the revoker also closes the node's mutual-TLS sockets, because the CRL is
424
+ * only read at a handshake), (2) `disconnected` in the catalog, which unregisters its services and
425
+ * rejects the cap calls still waiting on it with `RequestRejectedError` - a bare `nodes.delete`
426
+ * would leave both behind, (3) the delete and the rest of the cleanup. The agent finds out on
427
+ * its own: refused at the next handshake, it asks the hub (`agent-membership-watch`) and resets
428
+ * to unpaired after two CA-signed `revoked` answers. `stillListed` is read back at the end, so a
429
+ * node that came straight back (revocation failed) is reported as listed, never as forgotten.
425
430
  */
426
431
  async forgetClusterNode(nodeId) {
427
- if (this.isNodeOnline(nodeId)) {
428
- const message = `Refused: ${nodeId} is ONLINE. Take the node down first — a live node re-registers immediately.`;
429
- this.logger?.warn('forget cluster node refused', {
430
- tags: { nodeId },
431
- meta: { reason: 'node-online' },
432
- });
433
- return {
434
- forgotten: false,
435
- historyRemoved: false,
436
- registryRemoved: false,
437
- stillListed: true,
438
- certificates: { outcome: 'not-attempted' },
439
- message,
440
- };
441
- }
442
432
  // First: a forgotten node whose leaf stays valid walks back in on its next boot (D857).
443
433
  const certificates = await this.revokeForgottenNodeCertificates(nodeId);
444
434
  const historyOutcome = this.historyStore
@@ -446,6 +436,8 @@ class AgentRegistryService {
446
436
  : 'store-unavailable';
447
437
  // The mirror still lists the forgotten node: the next directory re-reads.
448
438
  this.historyMirror?.invalidate();
439
+ if (this.isNodeOnline(nodeId))
440
+ this.disconnectFromNodeCatalog(nodeId);
449
441
  const registryRemoved = this.removeFromNodeCatalog(nodeId);
450
442
  // Its intended set is gone with the row, so the back-fill breaker's memory
451
443
  // of failed deliveries to it is meaningless — drop it too.
@@ -499,6 +491,20 @@ class AgentRegistryService {
499
491
  message,
500
492
  };
501
493
  }
494
+ /**
495
+ * Take a LIVE node out of routing the way Moleculer does when its transport drops: services
496
+ * unregistered, pending requests rejected (typed, not left to time out). Idempotent.
497
+ */
498
+ disconnectFromNodeCatalog(nodeId) {
499
+ const nodes = this.nodeCatalogRegistry()?.nodes;
500
+ if (nodes?.disconnected === undefined) {
501
+ this.logger?.warn('cluster node catalog does not expose disconnected - calls may hang', {
502
+ tags: { nodeId },
503
+ });
504
+ return;
505
+ }
506
+ nodes.disconnected(nodeId, false);
507
+ }
502
508
  /** Is the node currently AVAILABLE in Moleculer's catalog? */
503
509
  isNodeOnline(nodeId) {
504
510
  return this.catalogNodeIds({ onlyAvailable: true }).includes(nodeId);
@@ -1173,7 +1179,8 @@ class AgentRegistryService {
1173
1179
  status: {
1174
1180
  activeCameras: 0,
1175
1181
  cpuPercent: status.cpuPercent ?? 0,
1176
- memoryPercent: status.memoryPercent ?? 0,
1182
+ memoryPercent: typeof status.memoryPercent === 'number' ? status.memoryPercent : null,
1183
+ processRssBytes: typeof status.processRssBytes === 'number' ? status.processRssBytes : null,
1177
1184
  fps: {},
1178
1185
  errors: [],
1179
1186
  },
@@ -1237,7 +1244,7 @@ class AgentRegistryService {
1237
1244
  status: {
1238
1245
  activeCameras: 0,
1239
1246
  cpuPercent: 0,
1240
- memoryPercent: 0,
1247
+ memoryPercent: null,
1241
1248
  fps: {},
1242
1249
  errors: [],
1243
1250
  },
@@ -1254,7 +1261,7 @@ class AgentRegistryService {
1254
1261
  // The cap contract only exposes async snapshots; the cached read is cheap
1255
1262
  // because the addon's background sampler keeps the snapshot warm.
1256
1263
  let cpuPercent = 0;
1257
- let memoryPercent = 0;
1264
+ let memoryPercent = null;
1258
1265
  const registry = this.capabilityService.getRegistry();
1259
1266
  if (registry) {
1260
1267
  const metricsProvider = registry.getSingleton('metrics-provider');
@@ -1283,6 +1290,7 @@ class AgentRegistryService {
1283
1290
  activeCameras: 0,
1284
1291
  cpuPercent,
1285
1292
  memoryPercent,
1293
+ processRssBytes: process.memoryUsage().rss,
1286
1294
  fps: {},
1287
1295
  errors: [],
1288
1296
  },
@@ -759,6 +759,14 @@ class MoleculerService {
759
759
  // agents (non-retaining nodes).
760
760
  const hubChildRegistry = this.localChildRegistry;
761
761
  (0, system_1.setNodeEventInterest)(this.broker, () => hubChildRegistry.aggregateEventInterest());
762
+ // Producer-side suppression (D8): tell each forked child which categories
763
+ // someone else wants — siblings plus the hub's own in-process subscribers
764
+ // (the admin live stream is one) — so a high-frequency category nobody
765
+ // wants never leaves its process. Hub only: an agent cannot see the hub's
766
+ // subscribers, so its children keep sending everything. A remote node's
767
+ // interest in a ring-denied category is NOT visible here (ADR 0969).
768
+ hubChildRegistry.setEventInterestSource(() => (0, system_1.getLocalEventInterest)(this.broker));
769
+ (0, system_1.onLocalEventInterestChange)(this.broker, () => hubChildRegistry.refreshEventInterest());
762
770
  }
763
771
  }
764
772
  /**
@@ -0,0 +1,255 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ALLOCATOR_ENV_DEFAULTS = exports.OVERRIDE_MAX_MB = exports.OVERRIDE_MIN_MB = exports.AGENT_HEAP_CAP_MB = exports.AGENT_HEAP_FLOOR_MB = exports.HEAP_CEILING_SOURCE_ENV = exports.LAUNCH_OVERRIDES_FILE = void 0;
37
+ exports.computedAgentHeapMb = computedAgentHeapMb;
38
+ exports.planLaunch = planLaunch;
39
+ exports.renderLaunchPlan = renderLaunchPlan;
40
+ exports.overridesPath = overridesPath;
41
+ exports.readHeapOverrideMb = readHeapOverrideMb;
42
+ exports.writeHeapOverrideMb = writeHeapOverrideMb;
43
+ exports.readCgroupLimitBytes = readCgroupLimitBytes;
44
+ exports.runLaunchCli = runLaunchCli;
45
+ /**
46
+ * NODE LAUNCH ARGUMENTS — decided by the server closure, executed by the image.
47
+ *
48
+ * `node <closure>/dist/launch-args.js --role hub|agent [--allocator-env]` prints
49
+ * what the launcher should add to the node's command line, so the Docker
50
+ * entrypoint, the Electron shell and the Windows service supervisor carry NO copy
51
+ * of the formula: changing it (or a node's override) needs a closure update and a
52
+ * restart, never a new image or app.
53
+ *
54
+ * Zero dependencies (node built-ins only): it runs BEFORE the closure's own
55
+ * `node_modules` resolution, from either the active closure or the baked seed.
56
+ *
57
+ * ## Output (stdout, one record per line, `\n` separated)
58
+ *
59
+ * arg --max-old-space-size=2048 an extra node argv entry (flags only)
60
+ * env NAME=value an environment variable to export
61
+ * source override|legacy-env|computed|v8-default
62
+ * warn <free text> something the launcher should log
63
+ *
64
+ * Nothing else is printed. A launcher treats any line it does not recognise as
65
+ * absent, and a non-zero exit, a timeout or empty output as "no extra args".
66
+ *
67
+ * ## The heap ceiling
68
+ *
69
+ * - `override` — `<dataDir>/launch-overrides.json` `{ "heapMb": N }`, set from
70
+ * the hub (`serverManagement.setLaunchOverrides`) and persisted on the node.
71
+ * - `legacy-env` — `CAMSTACK_HUB_HEAP_MB` (hub) / `CAMSTACK_AGENT_HEAP_MB`
72
+ * (agent), still honoured when no override is set.
73
+ * - `computed` — AGENT only: clamp(min(RAM, cgroup limit) / 4, 1536, 4096) MB.
74
+ * V8's own quarter-of-RAM rule with a floor above every measured agent RSS
75
+ * (0.8-1.4 GB), so it bounds a runaway and never a healthy root.
76
+ * - `v8-default` — HUB only, nothing set: no flag. A hardcoded 1536 killed the
77
+ * hub four times in 15 h (see the history in the Dockerfile); V8's default
78
+ * here was 4288 MB and is still bounded.
79
+ *
80
+ * Set on argv, never NODE_OPTIONS: forked addon runners are sized separately.
81
+ */
82
+ const fs = __importStar(require("node:fs"));
83
+ const os = __importStar(require("node:os"));
84
+ const path = __importStar(require("node:path"));
85
+ exports.LAUNCH_OVERRIDES_FILE = 'launch-overrides.json';
86
+ /** Env var carrying {@link CeilingSource} to the node so its heartbeat can say who chose. */
87
+ exports.HEAP_CEILING_SOURCE_ENV = 'CAMSTACK_HEAP_CEILING_SOURCE';
88
+ exports.AGENT_HEAP_FLOOR_MB = 1536;
89
+ exports.AGENT_HEAP_CAP_MB = 4096;
90
+ /** An override outside this range is ignored with a warning, never applied. */
91
+ exports.OVERRIDE_MIN_MB = 512;
92
+ exports.OVERRIDE_MAX_MB = 65536;
93
+ /**
94
+ * The native allocator pair, welded: glibc reads MALLOC_ARENA_MAX once at malloc
95
+ * init, so it must exist before `exec node`; sharp restores libvips concurrency
96
+ * from 1 to `availableParallelism()` the moment it appears, so the pair travels
97
+ * together or not at all. Defaults only: an operator value already in the
98
+ * environment wins (the launcher applies them with `: "${NAME:=value}"` semantics).
99
+ */
100
+ exports.ALLOCATOR_ENV_DEFAULTS = {
101
+ MALLOC_ARENA_MAX: '2',
102
+ VIPS_CONCURRENCY: '1',
103
+ };
104
+ const BYTES_PER_MB = 1024 * 1024;
105
+ /** cgroup v1 reports "unlimited" as a value near 2^63. */
106
+ const CGROUP_UNLIMITED_BYTES = 2 ** 60;
107
+ function validMb(value) {
108
+ return Number.isInteger(value) && value >= exports.OVERRIDE_MIN_MB && value <= exports.OVERRIDE_MAX_MB;
109
+ }
110
+ /** The agent's computed ceiling for `host`, in MB. */
111
+ function computedAgentHeapMb(host) {
112
+ const limits = [host.totalMemBytes, host.cgroupLimitBytes ?? Number.POSITIVE_INFINITY].filter((n) => Number.isFinite(n) && n > 0);
113
+ if (limits.length === 0)
114
+ return exports.AGENT_HEAP_FLOOR_MB;
115
+ const quarterMb = Math.floor(Math.min(...limits) / BYTES_PER_MB / 4);
116
+ return Math.min(exports.AGENT_HEAP_CAP_MB, Math.max(exports.AGENT_HEAP_FLOOR_MB, quarterMb));
117
+ }
118
+ function planLaunch(input) {
119
+ const warnings = [];
120
+ let source = input.role === 'agent' ? 'computed' : 'v8-default';
121
+ let heapMb = input.role === 'agent' ? computedAgentHeapMb(input.host) : null;
122
+ const legacy = input.legacyEnv;
123
+ if (legacy !== undefined && legacy !== '') {
124
+ const mb = /^[1-9][0-9]*$/.test(legacy) ? Number(legacy) : Number.NaN;
125
+ if (validMb(mb)) {
126
+ source = 'legacy-env';
127
+ heapMb = mb;
128
+ }
129
+ else {
130
+ warnings.push(`ignoring the legacy heap env "${legacy}": not an integer in ${exports.OVERRIDE_MIN_MB}-${exports.OVERRIDE_MAX_MB} MB`);
131
+ }
132
+ }
133
+ if (input.overrideMb !== null) {
134
+ if (validMb(input.overrideMb)) {
135
+ source = 'override';
136
+ heapMb = input.overrideMb;
137
+ }
138
+ else {
139
+ warnings.push(`ignoring the heap override ${input.overrideMb}: not an integer in ${exports.OVERRIDE_MIN_MB}-${exports.OVERRIDE_MAX_MB} MB`);
140
+ }
141
+ }
142
+ return {
143
+ args: heapMb === null ? [] : [`--max-old-space-size=${heapMb}`],
144
+ env: {
145
+ [exports.HEAP_CEILING_SOURCE_ENV]: source,
146
+ ...(input.allocatorEnv ? exports.ALLOCATOR_ENV_DEFAULTS : {}),
147
+ },
148
+ source,
149
+ warnings,
150
+ };
151
+ }
152
+ function renderLaunchPlan(plan) {
153
+ const lines = [
154
+ ...plan.args.map((a) => `arg ${a}`),
155
+ ...Object.entries(plan.env).map(([k, v]) => `env ${k}=${v}`),
156
+ `source ${plan.source}`,
157
+ ...plan.warnings.map((w) => `warn ${w.replace(/[\r\n]+/g, ' ')}`),
158
+ ];
159
+ return `${lines.join('\n')}\n`;
160
+ }
161
+ const realFs = {
162
+ readFile(file) {
163
+ try {
164
+ return fs.readFileSync(file, 'utf8');
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ },
170
+ };
171
+ function overridesPath(dataDir) {
172
+ return path.join(dataDir, exports.LAUNCH_OVERRIDES_FILE);
173
+ }
174
+ /** The persisted heap override in MB; `null` when absent, unreadable or not a number. */
175
+ function readHeapOverrideMb(dataDir, io = realFs) {
176
+ const raw = io.readFile(overridesPath(dataDir));
177
+ if (raw === null)
178
+ return null;
179
+ try {
180
+ const parsed = JSON.parse(raw);
181
+ if (typeof parsed !== 'object' || parsed === null || !('heapMb' in parsed))
182
+ return null;
183
+ const value = parsed.heapMb;
184
+ return typeof value === 'number' ? value : null;
185
+ }
186
+ catch {
187
+ return null;
188
+ }
189
+ }
190
+ /**
191
+ * Persist (or, with `null`, clear) the heap override. Validated against the same
192
+ * range the launcher enforces, so a value the next launch would ignore is refused
193
+ * here instead of silently doing nothing. Written atomically (temp + rename).
194
+ */
195
+ function writeHeapOverrideMb(dataDir, heapMb) {
196
+ const file = overridesPath(dataDir);
197
+ if (heapMb === null) {
198
+ fs.rmSync(file, { force: true });
199
+ return;
200
+ }
201
+ if (!validMb(heapMb)) {
202
+ throw new Error(`heap override must be an integer in ${exports.OVERRIDE_MIN_MB}-${exports.OVERRIDE_MAX_MB} MB, got ${heapMb}`);
203
+ }
204
+ fs.mkdirSync(dataDir, { recursive: true });
205
+ const tmp = `${file}.${process.pid}.tmp`;
206
+ fs.writeFileSync(tmp, `${JSON.stringify({ heapMb })}\n`, { mode: 0o600 });
207
+ fs.renameSync(tmp, file);
208
+ }
209
+ function readCgroupLimitBytes(io = realFs, files) {
210
+ for (const file of files ?? [
211
+ '/sys/fs/cgroup/memory.max',
212
+ '/sys/fs/cgroup/memory/memory.limit_in_bytes',
213
+ ]) {
214
+ const raw = io.readFile(file)?.trim();
215
+ if (raw === undefined || !/^[0-9]+$/.test(raw))
216
+ continue;
217
+ const bytes = Number(raw);
218
+ if (bytes > 0 && bytes < CGROUP_UNLIMITED_BYTES)
219
+ return bytes;
220
+ }
221
+ return null;
222
+ }
223
+ function parseRole(argv) {
224
+ const i = argv.indexOf('--role');
225
+ const value = i >= 0 ? argv[i + 1] : undefined;
226
+ return value === 'hub' || value === 'agent' ? value : null;
227
+ }
228
+ /** Run the CLI; returns the stdout text and the exit code. Never throws. */
229
+ function runLaunchCli(argv, deps) {
230
+ const role = parseRole(argv);
231
+ if (role === null)
232
+ return { stdout: '', code: 2 };
233
+ const dataDir = path.resolve(deps.env['CAMSTACK_DATA'] ?? 'camstack-data');
234
+ const legacyName = role === 'hub' ? 'CAMSTACK_HUB_HEAP_MB' : 'CAMSTACK_AGENT_HEAP_MB';
235
+ const plan = planLaunch({
236
+ role,
237
+ host: {
238
+ totalMemBytes: deps.totalMemBytes,
239
+ cgroupLimitBytes: readCgroupLimitBytes(deps.io, deps.cgroupFiles),
240
+ },
241
+ overrideMb: readHeapOverrideMb(dataDir, deps.io),
242
+ legacyEnv: deps.env[legacyName],
243
+ allocatorEnv: argv.includes('--allocator-env'),
244
+ });
245
+ return { stdout: renderLaunchPlan(plan), code: 0 };
246
+ }
247
+ if (require.main === module) {
248
+ const result = runLaunchCli(process.argv.slice(2), {
249
+ env: process.env,
250
+ io: realFs,
251
+ totalMemBytes: os.totalmem(),
252
+ });
253
+ process.stdout.write(result.stdout);
254
+ process.exitCode = result.code;
255
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.688",
3
+ "version": "1.2.690",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,7 +24,8 @@
24
24
  "./package.json": "./package.json",
25
25
  "./main.js": "./dist/main.js",
26
26
  "./launcher.js": "./dist/launcher.js",
27
- "./starter.js": "./dist/starter.js"
27
+ "./starter.js": "./dist/starter.js",
28
+ "./launch-args.js": "./dist/launch-args.js"
28
29
  },
29
30
  "scripts": {
30
31
  "build": "../../scripts/tsc7 -p tsconfig.build.json && tsup && node scripts/copy-closure-patches.mjs",
@@ -49,10 +50,10 @@
49
50
  ]
50
51
  },
51
52
  "dependencies": {
52
- "@camstack/addon-admin-ui": "1.2.516",
53
- "@camstack/addon-agent-ui": "1.2.291",
53
+ "@camstack/addon-admin-ui": "1.2.518",
54
+ "@camstack/addon-agent-ui": "1.2.293",
54
55
  "@camstack/addon-auth": "1.2.170",
55
- "@camstack/addon-benchmark": "1.2.290",
56
+ "@camstack/addon-benchmark": "1.2.292",
56
57
  "@camstack/addon-decoder-nodeav": "1.2.144",
57
58
  "@camstack/addon-notifiers": "1.2.623",
58
59
  "@camstack/addon-pipeline": "1.2.634",
@@ -60,9 +61,9 @@
60
61
  "@camstack/addon-post-analysis": "1.2.651",
61
62
  "@camstack/develop": "0.1.39",
62
63
  "@camstack/sdk": "1.2.154",
63
- "@camstack/system": "1.2.517",
64
- "@camstack/types": "1.2.443",
65
- "@camstack/ui-library": "1.2.349",
64
+ "@camstack/system": "1.2.519",
65
+ "@camstack/types": "1.2.445",
66
+ "@camstack/ui-library": "1.2.351",
66
67
  "@fastify/compress": "^9.0.0",
67
68
  "@fastify/cookie": "^11.0.2",
68
69
  "@fastify/cors": "^11.2.0",