@camstack/server 1.0.8 → 1.1.1

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.
@@ -33,6 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.getDeployStageRegistry = getDeployStageRegistry;
37
+ exports.registerDeployBundleRoute = registerDeployBundleRoute;
38
+ exports.buildHubHttpSource = buildHubHttpSource;
36
39
  exports.registerAddonUploadRoute = registerAddonUploadRoute;
37
40
  const fs = __importStar(require("node:fs"));
38
41
  const path = __importStar(require("node:path"));
@@ -40,6 +43,40 @@ const os = __importStar(require("node:os"));
40
43
  const node_child_process_1 = require("node:child_process");
41
44
  const scope_access_js_1 = require("./trpc/scope-access.js");
42
45
  const addon_package_service_js_1 = require("../core/addon/addon-package.service.js");
46
+ const deploy_stage_registry_js_1 = require("./deploy-stage-registry.js");
47
+ const deployStageRegistry = new deploy_stage_registry_js_1.DeployStageRegistry();
48
+ function getDeployStageRegistry() {
49
+ return deployStageRegistry;
50
+ }
51
+ /**
52
+ * One-time-token route agents pull a staged addon tgz from. Bearer token is
53
+ * the per-deploy token minted in `DeployStageRegistry.stage()` and carried to
54
+ * the agent in the `$agent.deploy` `hub-http` source descriptor.
55
+ */
56
+ function registerDeployBundleRoute(fastify) {
57
+ // `compress: false` — never gzip/brotli this route. The body is a large
58
+ // binary tarball (addon bundle / model artifact) the agent pulls with undici
59
+ // + sha256 verification. With global @fastify/compress on, the 1–2MB response
60
+ // was Brotli-encoded and undici's BrotliDecompress aborted the stream
61
+ // ("TypeError: terminated"), silently breaking EVERY agent-pull (addon-deploy
62
+ // AND model distribution). Identity bytes also keep the byte-length check valid.
63
+ fastify.get('/api/addons/deploy-bundle/:id', { compress: false }, (request, reply) => {
64
+ const auth = request.headers.authorization ?? '';
65
+ const token = auth.startsWith('Bearer ') ? auth.slice('Bearer '.length) : '';
66
+ if (!token) {
67
+ reply.status(401).send({ error: 'missing bearer token' });
68
+ return;
69
+ }
70
+ const buffer = deployStageRegistry.consume(request.params.id, token);
71
+ if (!buffer) {
72
+ reply
73
+ .status(deployStageRegistry.peekExists(request.params.id) ? 401 : 404)
74
+ .send({ error: 'bundle not available' });
75
+ return;
76
+ }
77
+ reply.header('content-type', 'application/octet-stream').send(buffer);
78
+ });
79
+ }
43
80
  /**
44
81
  * Validate a `cst_*` scoped token via the `user-management` cap singleton.
45
82
  *
@@ -76,6 +113,20 @@ function isUploadAllowed(scoped) {
76
113
  const TARBALL_EXTENSIONS = ['.tgz', '.tar.gz'];
77
114
  const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
78
115
  const AGENT_DEPLOY_TIMEOUT_MS = 60_000;
116
+ const AGENT_DEPLOY_CONTROL_TIMEOUT_MS = 30_000;
117
+ function hubBundleBaseUrl() {
118
+ return process.env['CAMSTACK_HUB_PUBLIC_URL'] ?? 'https://127.0.0.1:4443';
119
+ }
120
+ function buildHubHttpSource(buffer, hubBaseUrl) {
121
+ const staged = getDeployStageRegistry().stage(buffer);
122
+ return {
123
+ kind: 'hub-http',
124
+ url: `${hubBaseUrl.replace(/\/$/, '')}/api/addons/deploy-bundle/${staged.id}`,
125
+ token: staged.token,
126
+ sha256: staged.sha256,
127
+ bytes: staged.bytes,
128
+ };
129
+ }
79
130
  function isTarball(filename) {
80
131
  return TARBALL_EXTENSIONS.some((ext) => filename.endsWith(ext));
81
132
  }
@@ -119,6 +170,7 @@ async function registerAddonUploadRoute(fastify, addonBridge, authService, molec
119
170
  await fastify.register(Promise.resolve().then(() => __importStar(require('@fastify/multipart'))), {
120
171
  limits: { fileSize: MAX_UPLOAD_BYTES },
121
172
  });
173
+ registerDeployBundleRoute(fastify);
122
174
  fastify.post('/api/addons/upload', async (request, reply) => {
123
175
  const authHeader = request.headers.authorization;
124
176
  if (!authHeader) {
@@ -206,11 +258,12 @@ async function registerAddonUploadRoute(fastify, addonBridge, authService, molec
206
258
  // the hub AND broadcasts to every connected agent that can run any of
207
259
  // the package's addons (i.e. anything not marked hub-only). Any other
208
260
  // explicit `nodeId` value routes only to that agent via `$agent.deploy`.
261
+ const baseUrl = hubBundleBaseUrl();
209
262
  if (!nodeId || nodeId === 'hub') {
210
- return installToHub(reply, addonBridge, addonRegistry, addonPackageService, moleculer, logger, data.filename, buffer);
263
+ return installToHub(reply, addonBridge, addonRegistry, addonPackageService, moleculer, logger, data.filename, buffer, baseUrl);
211
264
  }
212
265
  const agentAddonId = addonIdHint ?? manifest.name;
213
- return deployToAgent(reply, moleculer, nodeId, agentAddonId, buffer);
266
+ return deployToAgent(reply, moleculer, nodeId, agentAddonId, buffer, baseUrl);
214
267
  });
215
268
  }
216
269
  /**
@@ -248,7 +301,7 @@ function packageHasAgentDeployable(addonsDir, packageName) {
248
301
  * without an agent restart. Agents that fail are reported per-node — one
249
302
  * unreachable agent must not block the others or the hub install.
250
303
  */
251
- async function propagateToAgents(moleculer, logger, packageName, buffer) {
304
+ async function propagateToAgents(moleculer, logger, packageName, buffer, hubBaseUrl) {
252
305
  const broker = moleculer.broker;
253
306
  const nodes = broker.registry?.getNodeList?.({ onlyAvailable: true }) ?? [];
254
307
  // Moleculer reports both top-level nodes (`hub`, `dev-agent-0`, …) AND
@@ -270,7 +323,13 @@ async function propagateToAgents(moleculer, logger, packageName, buffer) {
270
323
  const results = [];
271
324
  for (const nodeId of agentNodeIds) {
272
325
  try {
273
- const deployRaw = await broker.call('$agent.deploy', { addonId: packageName, bundle: buffer }, { nodeID: nodeId, timeout: AGENT_DEPLOY_TIMEOUT_MS });
326
+ // The hub currently only ever sends the `hub-http` source (agent streams
327
+ // the tgz directly from this hub). The `{kind:'npm'}` source is a reserved
328
+ // follow-up (published-addon optimization) and is intentionally not produced
329
+ // yet. Wiring it later must also make the agent's npm path evict the addon's
330
+ // declaration ids before `$agent.reload`, otherwise an npm update pins the
331
+ // stale version.
332
+ const deployRaw = await broker.call('$agent.deploy', { addonId: packageName, source: buildHubHttpSource(buffer, hubBaseUrl) }, { nodeID: nodeId, timeout: AGENT_DEPLOY_CONTROL_TIMEOUT_MS });
274
333
  if (!isAgentDeployResponse(deployRaw)) {
275
334
  results.push({ nodeId, success: false, error: 'malformed deploy response' });
276
335
  continue;
@@ -302,7 +361,7 @@ async function propagateToAgents(moleculer, logger, packageName, buffer) {
302
361
  * Without this the CLI push was write-to-disk-only and required a server
303
362
  * restart to actually run the new code.
304
363
  */
305
- async function installToHub(reply, addonBridge, addonRegistry, addonPackageService, moleculer, logger, filename, buffer) {
364
+ async function installToHub(reply, addonBridge, addonRegistry, addonPackageService, moleculer, logger, filename, buffer, hubBaseUrl) {
306
365
  const tmpDir = path.join(os.tmpdir(), `camstack-addon-upload-${Date.now()}`);
307
366
  fs.mkdirSync(tmpDir, { recursive: true });
308
367
  const tgzPath = path.join(tmpDir, filename);
@@ -401,7 +460,7 @@ async function installToHub(reply, addonBridge, addonRegistry, addonPackageServi
401
460
  });
402
461
  }
403
462
  if (propagatable) {
404
- void propagateToAgents(moleculer, logger, result.name, buffer).then((agentResults) => {
463
+ void propagateToAgents(moleculer, logger, result.name, buffer, hubBaseUrl).then((agentResults) => {
405
464
  logger.info('propagation done', {
406
465
  meta: { packageName: result.name, agents: agentResults },
407
466
  });
@@ -434,10 +493,16 @@ function isAgentDeployResponse(value) {
434
493
  return false;
435
494
  return true;
436
495
  }
437
- async function deployToAgent(reply, moleculer, nodeId, addonId, buffer) {
496
+ async function deployToAgent(reply, moleculer, nodeId, addonId, buffer, hubBaseUrl) {
438
497
  try {
439
498
  const broker = moleculer.broker;
440
- const raw = await broker.call('$agent.deploy', { addonId, bundle: buffer }, { nodeID: nodeId, timeout: AGENT_DEPLOY_TIMEOUT_MS });
499
+ // The hub currently only ever sends the `hub-http` source (agent streams
500
+ // the tgz directly from this hub). The `{kind:'npm'}` source is a reserved
501
+ // follow-up (published-addon optimization) and is intentionally not produced
502
+ // yet. Wiring it later must also make the agent's npm path evict the addon's
503
+ // declaration ids before `$agent.reload`, otherwise an npm update pins the
504
+ // stale version.
505
+ const raw = await broker.call('$agent.deploy', { addonId, source: buildHubHttpSource(buffer, hubBaseUrl) }, { nodeID: nodeId, timeout: AGENT_DEPLOY_CONTROL_TIMEOUT_MS });
441
506
  if (!isAgentDeployResponse(raw)) {
442
507
  return reply.status(502).send({ error: 'Agent deploy returned malformed response' });
443
508
  }
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DeployStageRegistry = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ /**
6
+ * In-memory, single-use, TTL-bounded store of staged addon tarballs the hub
7
+ * serves to agents over HTTP. One entry per deploy target; the token is
8
+ * consumed on the first successful read so a bundle URL is never reusable.
9
+ */
10
+ class DeployStageRegistry {
11
+ entries = new Map();
12
+ ttlMs;
13
+ now;
14
+ constructor(opts) {
15
+ this.ttlMs = opts?.ttlMs ?? 300_000;
16
+ this.now = opts?.now ?? Date.now;
17
+ }
18
+ stage(buffer) {
19
+ // Reclaim abandoned (never-consumed) entries before inserting a new one.
20
+ this.sweepExpired();
21
+ const id = (0, node_crypto_1.randomUUID)();
22
+ const token = (0, node_crypto_1.randomUUID)();
23
+ const sha256 = (0, node_crypto_1.createHash)('sha256').update(buffer).digest('hex');
24
+ this.entries.set(id, { token, buffer, sha256, expiresAt: this.now() + this.ttlMs });
25
+ return { id, token, sha256, bytes: buffer.length };
26
+ }
27
+ consume(id, token) {
28
+ const entry = this.entries.get(id);
29
+ if (!entry)
30
+ return null;
31
+ if (this.now() > entry.expiresAt) {
32
+ this.entries.delete(id);
33
+ return null;
34
+ }
35
+ // Constant-time comparison: different byte lengths → not equal (no timing oracle).
36
+ const entryBuf = Buffer.from(entry.token);
37
+ const tokenBuf = Buffer.from(token);
38
+ if (entryBuf.length !== tokenBuf.length || !(0, node_crypto_1.timingSafeEqual)(entryBuf, tokenBuf))
39
+ return null;
40
+ this.entries.delete(id); // single-use
41
+ return entry.buffer;
42
+ }
43
+ peekExists(id) {
44
+ const e = this.entries.get(id);
45
+ if (!e)
46
+ return false;
47
+ if (this.now() > e.expiresAt) {
48
+ this.entries.delete(id);
49
+ return false;
50
+ }
51
+ return true;
52
+ }
53
+ sweepExpired() {
54
+ const t = this.now();
55
+ for (const [id, e] of this.entries)
56
+ if (t > e.expiresAt)
57
+ this.entries.delete(id);
58
+ }
59
+ }
60
+ exports.DeployStageRegistry = DeployStageRegistry;
@@ -0,0 +1,128 @@
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.tarModelFiles = tarModelFiles;
37
+ exports.distributeModelWith = distributeModelWith;
38
+ exports.buildModelDistributorProvider = buildModelDistributorProvider;
39
+ const fs = __importStar(require("node:fs"));
40
+ const path = __importStar(require("node:path"));
41
+ const node_child_process_1 = require("node:child_process");
42
+ const system_1 = require("@camstack/system");
43
+ const addon_upload_js_1 = require("./addon-upload.js");
44
+ const AGENT_DISTRIBUTE_TIMEOUT_MS = 60_000;
45
+ function hubModelsDir() {
46
+ return path.join(process.env['CAMSTACK_DATA'] ?? 'camstack-data', 'models');
47
+ }
48
+ function hubBundleBaseUrl() {
49
+ return process.env['CAMSTACK_HUB_PUBLIC_URL'] ?? 'https://127.0.0.1:4443';
50
+ }
51
+ /**
52
+ * `tar -czf -` the given files relative to `cwd`, buffering the gzip stream.
53
+ * Async (spawn, not execFileSync) so a large model doesn't block the event
54
+ * loop. The agent untars this exact buffer into its own modelsDir.
55
+ */
56
+ function tarModelFiles(cwd, files) {
57
+ return new Promise((resolve, reject) => {
58
+ const child = (0, node_child_process_1.spawn)('tar', ['-czf', '-', '-C', cwd, ...files]);
59
+ const chunks = [];
60
+ const errChunks = [];
61
+ child.stdout.on('data', (c) => chunks.push(c));
62
+ child.stderr.on('data', (c) => errChunks.push(c));
63
+ child.on('error', reject);
64
+ child.on('close', (code) => {
65
+ if (code === 0)
66
+ resolve(Buffer.concat(chunks));
67
+ else
68
+ reject(new Error(`tar exited ${code}: ${Buffer.concat(errChunks).toString().slice(0, 300)}`));
69
+ });
70
+ });
71
+ }
72
+ /**
73
+ * Core distribution logic (pure orchestration over the seam):
74
+ * precondition-check → collect → tar → stage → trigger the agent pull.
75
+ * `nodeId === 'hub'` is a no-op (the artifact is already resident).
76
+ */
77
+ async function distributeModelWith(seam, input) {
78
+ const { nodeId, modelId, format, entry } = input;
79
+ if (nodeId === 'hub') {
80
+ return { ok: true, sha256: '', bytes: 0, path: seam.modelsDir };
81
+ }
82
+ if (!seam.isPresent(entry, format)) {
83
+ throw new Error(`model ${modelId}/${format} not present on hub — download or convert it first`);
84
+ }
85
+ const files = (0, system_1.collectModelFiles)(entry, format).filter((f) => seam.fileExists(f));
86
+ if (files.length === 0) {
87
+ throw new Error(`model ${modelId}/${format} resolved no files to distribute`);
88
+ }
89
+ const tgz = await seam.tar(files);
90
+ const source = seam.stage(tgz);
91
+ const res = await seam.callAgent(nodeId, { modelId, format, source });
92
+ return { ok: true, sha256: source.sha256, bytes: source.bytes, path: res.path ?? '' };
93
+ }
94
+ /**
95
+ * Build the hub-resident `model-distributor` singleton provider. Mounted as a
96
+ * service-backed cap override in `trpc.router.ts` because it needs the
97
+ * Moleculer broker + the deploy-stage registry, which a forked addon can't
98
+ * reach.
99
+ */
100
+ function buildModelDistributorProvider(moleculer, loggingService) {
101
+ const logger = loggingService.createLogger('model-distributor');
102
+ const modelsDir = hubModelsDir();
103
+ const seam = {
104
+ modelsDir,
105
+ isPresent: (entry, format) => (0, system_1.isModelDownloaded)(modelsDir, entry, format),
106
+ fileExists: (rel) => fs.existsSync(path.join(modelsDir, rel)),
107
+ tar: (files) => tarModelFiles(modelsDir, files),
108
+ stage: (buffer) => (0, addon_upload_js_1.buildHubHttpSource)(buffer, hubBundleBaseUrl()),
109
+ callAgent: async (nodeId, payload) => {
110
+ const broker = moleculer?.broker;
111
+ if (!broker)
112
+ throw new Error('model-distributor: Moleculer broker unavailable');
113
+ const raw = await broker.call('$agent.distributeModel', payload, {
114
+ nodeID: nodeId,
115
+ timeout: AGENT_DISTRIBUTE_TIMEOUT_MS,
116
+ });
117
+ return (raw ?? {});
118
+ },
119
+ };
120
+ return {
121
+ distributeModel: async (input) => {
122
+ logger.info('Distributing model to node', {
123
+ meta: { modelId: input.modelId, format: input.format, nodeId: input.nodeId },
124
+ });
125
+ return distributeModelWith(seam, input);
126
+ },
127
+ };
128
+ }
@@ -2,7 +2,7 @@
2
2
  // AUTO-GENERATED by scripts/generate-cap-mounts.ts — DO NOT EDIT
3
3
  // Re-run: npx tsx scripts/generate-cap-mounts.ts
4
4
  //
5
- // Mounted: 127 Skipped (legacy): 6
5
+ // Mounted: 130 Skipped (legacy): 6
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.LEGACY_SHAPE_SKIP = void 0;
8
8
  exports.mountAllCaps = mountAllCaps;
@@ -121,6 +121,22 @@ function mountAllCaps(services) {
121
121
  contact: (0, generated_cap_routers_js_1.createCapRouter_contact)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'contact'), remoteCapProxy),
122
122
  control: (0, generated_cap_routers_js_1.createCapRouter_control)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'control'), remoteCapProxy),
123
123
  cover: (0, generated_cap_routers_js_1.createCapRouter_cover)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'cover'), remoteCapProxy),
124
+ customModelRegistry: (0, generated_cap_routers_js_1.createCapRouter_customModelRegistry)((_ctx, addonId) => {
125
+ if (!reg)
126
+ return null;
127
+ if (addonId !== undefined) {
128
+ return reg.getProviderByAddonId('custom-model-registry', addonId);
129
+ }
130
+ const entries = reg.getCollectionEntries('custom-model-registry');
131
+ if (entries.length === 0)
132
+ return null;
133
+ const providers = entries.map(([, p]) => p);
134
+ const first = providers[0];
135
+ return {
136
+ ...first,
137
+ listModels: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'listModels'),
138
+ };
139
+ }, remoteCapProxy),
124
140
  decoder: (0, generated_cap_routers_js_1.createCapRouter_decoder)((_ctx) => reg?.getSingleton('decoder') ?? null, remoteCapProxy),
125
141
  detectionPipeline: (0, generated_cap_routers_js_1.createCapRouter_detectionPipeline)((_ctx) => reg?.getSingleton('detection-pipeline') ?? null, remoteCapProxy),
126
142
  deviceAdoption: (0, generated_cap_routers_js_1.createCapRouter_deviceAdoption)((_ctx) => reg?.getSingleton('device-adoption') ??
@@ -204,6 +220,9 @@ function mountAllCaps(services) {
204
220
  }, remoteCapProxy),
205
221
  metricsProvider: (0, generated_cap_routers_js_1.createCapRouter_metricsProvider)((_ctx) => reg?.getSingleton('metrics-provider') ??
206
222
  null, remoteCapProxy),
223
+ modelConvert: (0, generated_cap_routers_js_1.createCapRouter_modelConvert)((_ctx) => reg?.getSingleton('model-convert') ?? null, remoteCapProxy),
224
+ modelDistributor: (0, generated_cap_routers_js_1.createCapRouter_modelDistributor)((_ctx) => reg?.getSingleton('model-distributor') ??
225
+ null, remoteCapProxy),
207
226
  motion: (0, generated_cap_routers_js_1.createCapRouter_motion)((_ctx) => reg?.getSingleton('motion') ?? null, remoteCapProxy),
208
227
  motionDetection: (0, generated_cap_routers_js_1.createCapRouter_motionDetection)((_ctx) => reg?.getSingleton('motion-detection') ??
209
228
  null, remoteCapProxy),