@camstack/server 0.1.5 → 0.1.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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/addon-upload.spec.ts +58 -0
  3. package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
  4. package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
  5. package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
  6. package/src/__tests__/cap-route-adapter.spec.ts +289 -0
  7. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
  8. package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
  9. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
  10. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
  11. package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
  12. package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
  13. package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
  14. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +123 -0
  15. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
  16. package/src/__tests__/native-cap-route.spec.ts +404 -0
  17. package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
  18. package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
  19. package/src/__tests__/uds-log-ingest.spec.ts +183 -0
  20. package/src/api/addon-upload.ts +27 -1
  21. package/src/api/capabilities.router.ts +1 -1
  22. package/src/api/core/bulk-update-coordinator.ts +302 -0
  23. package/src/api/core/cap-providers.ts +59 -6
  24. package/src/api/core/capabilities.router.ts +26 -3
  25. package/src/api/oauth2/oauth2-routes.ts +5 -1
  26. package/src/api/trpc/__tests__/client-ip.spec.ts +120 -0
  27. package/src/api/trpc/cap-route-error-formatter.ts +163 -0
  28. package/src/api/trpc/client-ip.ts +130 -0
  29. package/src/api/trpc/generated-cap-mounts.ts +19 -1
  30. package/src/api/trpc/generated-cap-routers.ts +180 -1
  31. package/src/api/trpc/trpc.middleware.ts +5 -1
  32. package/src/api/trpc/trpc.router.ts +45 -0
  33. package/src/core/addon/addon-call-gateway.ts +157 -0
  34. package/src/core/addon/addon-package.service.ts +9 -0
  35. package/src/core/addon/addon-registry.service.ts +364 -105
  36. package/src/core/addon/addon-settings-provider.ts +40 -116
  37. package/src/core/capability/capability.service.ts +9 -0
  38. package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
  39. package/src/core/moleculer/cap-call-fn.ts +103 -0
  40. package/src/core/moleculer/cap-route-authority.ts +182 -0
  41. package/src/core/moleculer/moleculer.service.ts +380 -36
  42. package/src/main.ts +45 -12
@@ -16,8 +16,10 @@ interface BrokerLike {
16
16
  call(action: string, params?: unknown, opts?: unknown): Promise<unknown>
17
17
  waitForServices(services: string[], timeout?: number): Promise<unknown>
18
18
  }
19
- import { createBroker, createHubService, createProcessService, isInfraCapability, registerEventBusService, createReadinessServiceForRegistry, createStreamProbeBrokerService, createHwAccelService, createKernelHwAccel, HubNodeRegistry, serializeTypedArrays, callWithServiceDiscovery, hashClusterSecret } from '@camstack/kernel'
20
- import type { HubServiceDeps, CallFn, RegisterNodeParams, RegisteredAddonManifest } from '@camstack/kernel'
19
+ import { createBroker, createHubService, createProcessService, isInfraCapability, registerEventBusService, createReadinessServiceForRegistry, createStreamProbeBrokerService, createHwAccelService, createKernelHwAccel, HubNodeRegistry, serializeTypedArrays, callWithServiceDiscovery, hashClusterSecret, LocalChildRegistry, createLocalTransport, localEndpointPath, CapRouteResolver, CapRouteError, capActionName, udsChildLogToWorkerEntry, createUdsEventBridge, createParentUnownedCallHandler } from '@camstack/kernel'
20
+ import type { HubServiceDeps, CallFn, RegisterNodeParams, RegisteredAddonManifest, ChildCapDescriptor } from '@camstack/kernel'
21
+ import { buildCapCallFn } from './cap-call-fn.js'
22
+ import { createNodeCapAuthority, createInProcessProviderLookup } from './cap-route-authority.js'
21
23
  import { EventCategory, expandCapMethods, ReadinessRegistry, emitReadiness } from '@camstack/types'
22
24
  import type { CapabilityDefinition } from '@camstack/types'
23
25
  import { randomUUID } from 'node:crypto'
@@ -86,6 +88,44 @@ export class MoleculerService {
86
88
  * `expectedClusterSecretHash` so `$hub.registerNode` can gate on it.
87
89
  */
88
90
  private readonly clusterSecret: string | undefined
91
+ /**
92
+ * UDS server that listens for addon-runners spawned by this hub node.
93
+ * `null` when the UDS server failed to start (children run broker-only).
94
+ */
95
+ private localChildRegistry: LocalChildRegistry | null = null
96
+ /**
97
+ * Tracks cap names already logged as UDS-routed to avoid log spam.
98
+ * Cleared on no external event — one INFO line per distinct capName
99
+ * across the lifetime of the process.
100
+ */
101
+ private readonly udsRoutedCaps = new Set<string>()
102
+ /**
103
+ * CapRouteResolver — the single authority for cap dispatch routing.
104
+ * Constructed at the end of onModuleInit, once `localChildRegistry` is
105
+ * available and the broker has started. All cap dispatch flows through this.
106
+ */
107
+ private resolver: CapRouteResolver | null = null
108
+ /**
109
+ * Disposer returned by `createUdsEventBridge`. Called in `onModuleDestroy`
110
+ * to unsubscribe the bridge from the parent bus and clear the child-event
111
+ * handler, preventing subscriber leaks on shutdown.
112
+ */
113
+ private udsEventBridgeDispose: (() => void) | null = null
114
+
115
+ get childRegistry(): LocalChildRegistry | null {
116
+ return this.localChildRegistry
117
+ }
118
+
119
+ /** The CapRouteResolver once onModuleInit has completed; null before that. */
120
+ get capRouteResolver(): CapRouteResolver | null {
121
+ return this.resolver
122
+ }
123
+
124
+ /** This hub's Moleculer node id (e.g. `hub`). Hub-local forked children
125
+ * register under `${nodeId}/${runnerId}`. */
126
+ get nodeId(): string {
127
+ return this.brokerSafe.nodeID
128
+ }
89
129
 
90
130
  constructor(
91
131
  private readonly eventBus: EventBusService,
@@ -193,7 +233,86 @@ export class MoleculerService {
193
233
  this.brokerSafe.createService(hubService)
194
234
 
195
235
  const dataDir = this.config.get<string>('dataDir') ?? 'camstack-data'
196
- const processService: unknown = createProcessService(this.brokerSafe.nodeID, dataDir)
236
+
237
+ // UDS local transport: the hub hosts a LocalChildRegistry so its
238
+ // forked addon-runners route cap calls directly over a Unix-domain
239
+ // socket instead of through Moleculer. The broker stays available as
240
+ // the no-route fallback (remote agents + caps no local child owns).
241
+ // If the registry fails to start, children transparently fall back to
242
+ // broker-only — no parentUdsPath is propagated.
243
+ let parentUdsPath: string | undefined
244
+ try {
245
+ const nodeId = this.brokerSafe.nodeID
246
+ // F0 (slice-5 outbound): when a forked child issues `ctx.api.<cap>` for a
247
+ // cap NO local sibling owns, route it from the PARENT and return the
248
+ // result over UDS — resolver-first, broker-fallback. Closes over `this`
249
+ // so it reads `this.resolver` at CALL time (the resolver is constructed
250
+ // later in onModuleInit, after broker.start()). The broker fallback
251
+ // reaches the hub's core `$`-infra services (`$core-caps`, `$stream-probe`,
252
+ // settings-store, …) that are Moleculer services, NOT registered
253
+ // capabilities the resolver can see. Before F0 this fell through
254
+ // UDS_NO_ROUTE → the child's own brokerTransportLink; F1+F2 removes that
255
+ // child broker, so the parent must own this path.
256
+ const onUnownedCall = createParentUnownedCallHandler({
257
+ getResolver: () => this.resolver,
258
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
259
+ broker: this.broker,
260
+ logger: {
261
+ warn: (msg, meta) => logger.warn(msg, meta !== null && meta !== undefined ? { meta: meta as Record<string, unknown> } : undefined),
262
+ },
263
+ })
264
+ const registry = new LocalChildRegistry({
265
+ server: createLocalTransport().createServer(nodeId),
266
+ onUnownedCall,
267
+ logger: {
268
+ info: (msg, meta) => logger.info(msg, meta !== null && meta !== undefined ? { meta: meta as Record<string, unknown> } : undefined),
269
+ },
270
+ })
271
+ await registry.start()
272
+ // E1: apply child manifest + cleanup from the UDS lifecycle (hub-local children).
273
+ // When a runner connects over UDS, apply its cap descriptors to the
274
+ // CapabilityRegistry — same effect as the `$hub.registerNode` RPC for
275
+ // `hub/<runner>` nodes. This runs in PARALLEL with the RPC path until
276
+ // Phase F removes the child broker; `onRegisterNode`'s diff logic ensures
277
+ // double-apply is idempotent (same nodeId + same caps → no-op on the second call).
278
+ registry.onChildRegistered((child) => {
279
+ const hubNodeId = this.brokerSafe.nodeID
280
+ const nodeId = `${hubNodeId}/${child.childId}`
281
+ const params = buildChildUdsManifest(nodeId, child.childId, child.caps)
282
+ this.onRegisterNode(params)
283
+ logger.info('UDS child registered — manifest applied', { meta: { nodeId } })
284
+ })
285
+ // E1: cleanup on child disconnect — same effect as `$node.disconnected`
286
+ // for hub-local children. The Moleculer path stays for AGENT nodes.
287
+ registry.onChildGone((childId) => {
288
+ const hubNodeId = this.brokerSafe.nodeID
289
+ const nodeId = `${hubNodeId}/${childId}`
290
+ logger.info('UDS child gone — removing from registry', { meta: { childId } })
291
+ this.removeNodeFromRegistry(nodeId)
292
+ })
293
+ // B2: ingest UDS child logs into the hub's LoggingService so they appear
294
+ // in the LogManager / admin-UI log stream alongside broker-forwarded logs.
295
+ // This runs in PARALLEL with the existing $hub.log / onLog broker path —
296
+ // both stay active until Phase F removes the broker path.
297
+ registry.onChildLog((childId, entry) => {
298
+ this.logging.writeFromWorker(udsChildLogToWorkerEntry(childId, entry))
299
+ })
300
+ // D1: answer readiness-snapshot requests from UDS children so they can
301
+ // hydrate without a `$readiness.getSnapshot` Moleculer call.
302
+ // `this.readinessRegistry` is the hub-authoritative instance subscribed
303
+ // to the shared EventBusService — same source `$readiness.getSnapshot` uses.
304
+ // The handler is a live closure (calls `getSnapshotForTransport()` on each
305
+ // request) so children always receive the current snapshot, not a stale copy.
306
+ // Keep `$readiness.getSnapshot` intact — Phase F removes it.
307
+ registry.onReadinessSnapshotRequest(() => this.readinessRegistry.getSnapshotForTransport())
308
+ this.localChildRegistry = registry
309
+ parentUdsPath = localEndpointPath(nodeId)
310
+ logger.info('UDS child registry listening', { meta: { path: parentUdsPath } })
311
+ } catch (err) {
312
+ logger.warn('UDS child registry failed to start; children stay broker-only', { meta: { err: err instanceof Error ? err.message : String(err) } })
313
+ }
314
+
315
+ const processService: unknown = createProcessService(this.brokerSafe.nodeID, dataDir, undefined, undefined, parentUdsPath)
197
316
  this.brokerSafe.createService(processService)
198
317
 
199
318
  // $addonHost — REMOVED (Sprint 6). Three-level settings are now
@@ -241,6 +360,23 @@ export class MoleculerService {
241
360
  await this.brokerSafe.start()
242
361
  logger.info('Moleculer broker started (TCP transport)')
243
362
 
363
+ // Construct the CapRouteResolver now that both the broker and
364
+ // localChildRegistry are ready. The resolver reads live registry state
365
+ // via closure accessors on every call (not a frozen snapshot), so new
366
+ // children connecting/disconnecting after this point are picked up
367
+ // correctly. The localChildRegistry reference is captured and may be
368
+ // null if UDS failed to start.
369
+ this.resolver = new CapRouteResolver({
370
+ hubNodeId: this.brokerSafe.nodeID,
371
+ broker: this.brokerSafe,
372
+ hubLocalRegistry: this.localChildRegistry,
373
+ nodeAuthority: createNodeCapAuthority(this.nodeRegistry, {
374
+ resolveSingleton: (capName, nodeId) =>
375
+ this.capabilityService.getRegistry()?.resolveSingletonAddonIdForNode(capName, nodeId) ?? null,
376
+ }),
377
+ inProcessProviders: createInProcessProviderLookup(this.capabilityService),
378
+ })
379
+
244
380
  // Wire the hub's EventBusService into the broker so hub-addon
245
381
  // emissions fan out to every remote process via
246
382
  // `camstack.evt.<category>`, and incoming $event-bus events land on
@@ -250,6 +386,21 @@ export class MoleculerService {
250
386
  // duplicate delivery when `createBrokerEventBus` on a remote uses
251
387
  // both broadcast + `$hub.event` for back-compat.
252
388
  this.eventBus.attachBroker(this.broker)
389
+
390
+ // C2: wire the UDS ↔ Moleculer event bridge so events emitted by UDS
391
+ // children fan to siblings and reach the cluster, and cluster / parent-
392
+ // local events propagate to every UDS child. Inert when no children
393
+ // are connected (bridge just adds a no-op bus subscriber). The bridge
394
+ // is wired after attachBroker so the parentBus is backed by the real
395
+ // shared broker bus and broker.broadcast is live.
396
+ if (this.localChildRegistry !== null) {
397
+ const hubNodeId = this.brokerSafe.nodeID
398
+ this.udsEventBridgeDispose = createUdsEventBridge({
399
+ registry: this.localChildRegistry,
400
+ parentBus: this.eventBus,
401
+ parentNodeId: hubNodeId,
402
+ })
403
+ }
253
404
  }
254
405
 
255
406
  /**
@@ -306,8 +457,20 @@ export class MoleculerService {
306
457
 
307
458
  /**
308
459
  * Call a capability method on a specific node.
309
- * Hub: calls the local provider directly (same process, no Moleculer overhead).
310
- * Remote: routes through the stored Moleculer CallFn for that node.
460
+ *
461
+ * Delegates all routing decisions to the CapRouteResolver, which classifies
462
+ * the (capName, nodeId) pair into a typed CapRoute and dispatches to the
463
+ * appropriate transport (hub-in-process, hub-local-uds, remote-moleculer,
464
+ * agent-child-forward). A genuinely-absent cap throws CapRouteError (typed,
465
+ * with reason + rejected routes) instead of the old opaque error string.
466
+ *
467
+ * Falls back to the legacy findCallFn path when the resolver is not yet
468
+ * constructed (before onModuleInit completes) or when the resolver throws a
469
+ * no-provider / node-offline error for a node that IS in nodeCallFns — this
470
+ * handles the window between registerNode applying a callFn and the resolver
471
+ * seeing the new node (the resolver reads live registry state via closure
472
+ * accessors, but nodeCallFns is populated by applyNodeManifest which may
473
+ * have run before the resolver was constructed).
311
474
  */
312
475
  async callCapabilityOnNode(
313
476
  nodeId: string,
@@ -315,34 +478,59 @@ export class MoleculerService {
315
478
  methodName: string,
316
479
  params: unknown,
317
480
  ): Promise<unknown> {
318
- // Hub call local provider directly when registered on hub root.
319
- // For caps hosted in a per-addon runner (e.g. 'hub/detection-pipeline'),
320
- // the hub root registry has nothing; we then fall through to the
321
- // remote findCallFn prefix lookup which routes to the subnode.
481
+ const resolver = this.resolver
482
+ if (resolver !== null) {
483
+ // Extract deviceId from params so device-scoped native caps (ptz, motion-zones, …)
484
+ // resolve through the resolver's deviceId-aware snapshot instead of falling back to
485
+ // the legacy callFn store. The deviceId hint is a number extracted from the method args.
486
+ const rawDeviceId: unknown =
487
+ params !== null && typeof params === 'object' ? Reflect.get(params, 'deviceId') : undefined
488
+ const routeDeviceId: number | undefined = typeof rawDeviceId === 'number' ? rawDeviceId : undefined
489
+ try {
490
+ const route = resolver.resolveCapRoute(capabilityName, { nodeId, deviceId: routeDeviceId })
491
+ return await resolver.dispatch(route, methodName, params)
492
+ } catch (err) {
493
+ if (err instanceof CapRouteError && (err.reason === 'no-provider' || err.reason === 'node-offline')) {
494
+ // Resolver couldn't find the cap — try the legacy callFn store as a
495
+ // fallback. This covers caps registered in nodeCallFns (e.g. agent
496
+ // nodes that registered before the resolver's snapshot was built or
497
+ // caps that the resolver's nodeAuthority doesn't see yet because the
498
+ // resolver reads live registry state via closure accessors).
499
+ // Device-scoped native caps now resolve via the resolver (M1/M5 thread deviceId),
500
+ // so this fallback only handles genuinely-transitional stale-snapshot windows.
501
+ const callFn = this.findCallFn(nodeId, capabilityName)
502
+ if (callFn !== undefined) {
503
+ return callFn(methodName, params)
504
+ }
505
+ }
506
+ // Rethrow — includes transport-failed and all other errors
507
+ throw err
508
+ }
509
+ }
510
+
511
+ // Pre-init fallback (resolver not yet constructed — before onModuleInit).
512
+ // This path is only reachable in tests that drive onRegisterNode without
513
+ // calling onModuleInit first.
322
514
  if (nodeId === 'hub' || nodeId === this.brokerSafe.nodeID) {
323
515
  const registry = this.capabilityService.getRegistry()
324
- const provider = registry?.getSingleton(capabilityName) as Record<string, unknown> | null
325
- if (provider) {
516
+ const provider = registry?.getSingleton<Record<string, unknown>>(capabilityName) ?? null
517
+ if (provider !== null) {
326
518
  const fn = provider[methodName]
327
519
  if (typeof fn !== 'function') throw new Error(`Method "${methodName}" not found on "${capabilityName}"`)
328
- return (fn as (p: unknown) => unknown).call(provider, params)
520
+ return fn.call(provider, params)
329
521
  }
330
- // Fall through to the prefix-fallback findCallFn below.
331
522
  }
332
523
 
333
- // Remote — find stored callFn. The closure already captures the
334
- // FULL nodeId at registration time (e.g. `dev-agent-1/detection-pipeline`
335
- // for forkable addons). Passing the caller's `nodeId` here as the
336
- // 3rd arg would override the closure's capture with the bare parent
337
- // prefix (`dev-agent-1`), which Moleculer doesn't know about → the
338
- // call fails with "Service not found on '<prefix>' node". Omit it
339
- // so the closure routes to the correct worker node.
340
524
  const callFn = this.findCallFn(nodeId, capabilityName)
341
525
  if (callFn) {
342
526
  return callFn(methodName, params)
343
527
  }
344
528
 
345
- throw new Error(`Capability "${capabilityName}" not available on node "${nodeId}"`)
529
+ throw new CapRouteError(capabilityName, methodName, {
530
+ reason: 'no-provider',
531
+ nodeId,
532
+ rejected: [{ kind: 'hub-in-process', why: 'CapRouteResolver not initialised (pre-onModuleInit)' }],
533
+ })
346
534
  }
347
535
 
348
536
  /**
@@ -450,18 +638,46 @@ export class MoleculerService {
450
638
 
451
639
  const registryKey = registryKeyFor(addonId)
452
640
 
453
- // Build a callFn that routes through Moleculer, matching the shape
454
- // produced by CapabilityBridge: service name = addonId, action path
455
- // = `<addonId>.<capName>.<method>`, nodeID = the registering node.
456
- const callFn: CallFn =
457
- (method: string, methodParams: unknown, targetNodeId?: string) =>
641
+ // The runner id (= UDS childId) for a hub-local forked child is the
642
+ // trailing segment of its nodeId `${hubNodeId}/${runnerId}`. Only
643
+ // hub-local children are reachable over UDS; agent-hosted providers
644
+ // (`<agent>/<runner>`) fall through to Moleculer.
645
+ const udsChildId = isLocalChild ? nodeId.slice(hubNodeId.length + 1) : null
646
+
647
+ // Per-(cap,node) dispatcher. Routing lives in the unit-tested
648
+ // `buildCapCallFn` (see cap-call-fn.ts):
649
+ // - hub-local child → per-child UDS (collection-safe; keyed by runner
650
+ // id, never by capName which would collapse a COLLECTION cap onto
651
+ // the first child). Fails fast if the child isn't providing — NEVER
652
+ // a Moleculer fallback, since a hub-local child is not a Moleculer
653
+ // service (a broker call would hang the full discovery timeout).
654
+ // - agent-hosted / remote → the unified `CapRouteResolver`, which
655
+ // classifies an agent node as `agent-child-forward` (hub→agent→UDS
656
+ // child) and a direct remote as `remote-moleculer`. This closes the
657
+ // UDS-migration gap where this dispatcher hand-rolled a `broker.call`
658
+ // to an agent that exposes no Moleculer service for the cap.
659
+ // - resolver not yet built (pre-init) → legacy Moleculer call.
660
+ const callFn: CallFn = buildCapCallFn({
661
+ capName,
662
+ nodeId,
663
+ udsChildId,
664
+ getLocalChildRegistry: () => this.localChildRegistry,
665
+ getResolver: () => this.resolver,
666
+ legacyBrokerCall: (method, methodParams, targetNode) =>
458
667
  callWithServiceDiscovery(
459
668
  this.brokerSafe,
460
669
  addonId,
461
- `${addonId}.${capName}.${method}`,
670
+ capActionName(addonId, capName, method, false),
462
671
  serializeTypedArrays(methodParams),
463
- { nodeID: targetNodeId ?? nodeId, timeout: 60_000 },
464
- )
672
+ { nodeID: targetNode, timeout: 60_000 },
673
+ ),
674
+ onUdsRoute: (cap) => {
675
+ if (!this.udsRoutedCaps.has(cap)) {
676
+ this.udsRoutedCaps.add(cap)
677
+ this.logger.info('routing cap over UDS', { meta: { capName: cap } })
678
+ }
679
+ },
680
+ })
465
681
 
466
682
  const proxy: Record<string, unknown> = { id: addonId, nodeId }
467
683
  for (const methodName of Object.keys(expandCapMethods(capDef))) {
@@ -469,6 +685,23 @@ export class MoleculerService {
469
685
  }
470
686
  registry.registerProvider(capName, registryKey, proxy)
471
687
 
688
+ // Local-first singleton preference (UDS regression fix). A
689
+ // `placement: 'any-node'` singleton (e.g. `pipeline-executor`) can
690
+ // register on BOTH the hub-local forked child and a remote agent.
691
+ // `CapabilityRegistry` keeps the FIRST-registered provider active, so a
692
+ // race could leave the REMOTE agent proxy active — and its callFn routes
693
+ // over Moleculer to a UDS-only agent runner that no longer hosts the
694
+ // Moleculer service ("not found on <agent>"). The hub-local provider is
695
+ // reachable over UDS, so prefer it whenever the current active is absent
696
+ // or remote (`@`-keyed). Never steals from another local provider, so an
697
+ // operator's binding choice (a bare-key local provider) is preserved.
698
+ if (capDef.mode === 'singleton' && isLocalChild) {
699
+ const activeKey = registry.getSingletonAddonId(capName)
700
+ if (activeKey === null || activeKey.includes('@')) {
701
+ registry.setSingletonActiveAddon(capName, registryKey)
702
+ }
703
+ }
704
+
472
705
  // Emit AddonPageReady / AddonWidgetReady so the admin-UI sidebar
473
706
  // refreshes its page/widget registry for cross-process addons.
474
707
  if (capName === 'addon-pages-source') {
@@ -570,19 +803,60 @@ export class MoleculerService {
570
803
  }
571
804
 
572
805
 
806
+ /**
807
+ * Returns true when a (nodeId, capabilityName) pair is reachable via the
808
+ * legacy fallback paths: either a stored callFn in nodeCallFns, or a
809
+ * hub-local forked child that is reachable over UDS (even without a
810
+ * manifest callFn — e.g. device-scoped native caps). Used by
811
+ * createCapabilityProxy to decide whether to build a proxy when the
812
+ * CapRouteResolver cannot find a route.
813
+ */
814
+ private isReachableViaLegacy(nodeId: string, capabilityName: string): boolean {
815
+ if (this.findCallFn(nodeId, capabilityName) !== undefined) return true
816
+ return this.localChildRegistry !== null && nodeId.startsWith(`${this.brokerSafe.nodeID}/`)
817
+ }
818
+
573
819
  /**
574
820
  * Build a proxy object that forwards every method call on a capability
575
- * to a remote node via Moleculer. Returns null if the capability is not
576
- * reachable on that node. Used by the generated cap routers when a
577
- * request includes a `nodeId` field for transparent node routing.
821
+ * to the correct transport via CapRouteResolver. Returns null if the
822
+ * capability is provably not reachable on that node (resolver says no-provider
823
+ * AND no legacy callFn exists AND it is not a hub-local child).
824
+ *
825
+ * Used by the generated cap routers when a request includes a `nodeId` field
826
+ * for transparent node routing.
827
+ *
828
+ * Hub-local forked children (e.g. `hub/provider-reolink`) are reachable over
829
+ * UDS even when no manifest callFn exists — device-scoped NATIVE caps (ptz,
830
+ * motion-zones) aren't in `applyNodeManifest`'s callFn store. The proxy is
831
+ * built unconditionally for hub-local children so `callCapabilityOnNode` can
832
+ * route the actual method call via the resolver's hub-local-uds branch.
578
833
  */
579
834
  createCapabilityProxy(capabilityName: string, nodeId: string): Record<string, (params: unknown) => Promise<unknown>> | null {
580
- // Check if we can reach this capability on the target node
581
- const callFn = this.findCallFn(nodeId, capabilityName)
582
- if (!callFn) return null
835
+ const resolver = this.resolver
836
+ if (resolver !== null) {
837
+ // Use the resolver to determine reachability. If it resolves a route, we
838
+ // can build a proxy. If it throws no-provider but a legacy callFn exists
839
+ // or the node is a hub-local child (for native caps), build the proxy anyway
840
+ // because callCapabilityOnNode's fallback will handle it at dispatch time.
841
+ try {
842
+ resolver.resolveCapRoute(capabilityName, { nodeId })
843
+ // Resolver found a route — proxy is reachable.
844
+ } catch (err) {
845
+ if (err instanceof CapRouteError && (err.reason === 'no-provider' || err.reason === 'node-offline')) {
846
+ // Check legacy callFn store and hub-local child fallbacks.
847
+ if (!this.isReachableViaLegacy(nodeId, capabilityName)) return null
848
+ // Proxy reachable via fallback paths — fall through to build it.
849
+ } else {
850
+ throw err
851
+ }
852
+ }
853
+ } else {
854
+ // Pre-init: use legacy reachability check.
855
+ if (!this.isReachableViaLegacy(nodeId, capabilityName)) return null
856
+ }
583
857
 
584
858
  // Build a dynamic proxy: every property access returns a function that
585
- // calls the remote capability method via the stored callFn.
859
+ // routes the call through callCapabilityOnNode (which delegates to the resolver).
586
860
  return new Proxy<Record<string, (params: unknown) => Promise<unknown>>>({}, {
587
861
  get: (_target, methodName: string) => {
588
862
  return (params: unknown): Promise<unknown> =>
@@ -606,7 +880,77 @@ export class MoleculerService {
606
880
  return this.nodeRegistry.listNativeCapEntries()
607
881
  }
608
882
 
883
+ /**
884
+ * E2: Send a `set-log-level` UDS message to a hub-local child identified by
885
+ * `nodeId` (e.g. `hub/provider-reolink`). Extracts the `childId` from the
886
+ * nodeId and delegates to `LocalChildRegistry.setChildLogLevel`.
887
+ *
888
+ * Returns `true` only if the nodeId is a hub-local child (`hub/<childId}`) AND
889
+ * the child is currently connected to the LocalChildRegistry (the UDS message
890
+ * was emitted). Returns `false` when the nodeId is not a hub-local child, the
891
+ * registry is absent, or the child is not yet/no longer connected — in all
892
+ * three cases the caller (setProcessLogLevel in cap-providers.ts) falls back
893
+ * to the Moleculer `$node-mgmt.setLogLevel` action.
894
+ */
895
+ setChildLogLevelByNodeId(nodeId: string, level: string): boolean {
896
+ const hubNodeId = this.brokerSafe.nodeID
897
+ if (!nodeId.startsWith(`${hubNodeId}/`)) return false
898
+ const childId = nodeId.slice(hubNodeId.length + 1)
899
+ const registry = this.localChildRegistry
900
+ if (registry === null) return false
901
+ return registry.setChildLogLevel(childId, level)
902
+ }
903
+
609
904
  async onModuleDestroy(): Promise<void> {
905
+ this.udsEventBridgeDispose?.()
906
+ this.udsEventBridgeDispose = null
610
907
  await this.brokerSafe.stop()
908
+ await this.localChildRegistry?.close()
909
+ }
910
+ }
911
+
912
+ // ---------------------------------------------------------------------------
913
+ // Module-level helpers
914
+ // ---------------------------------------------------------------------------
915
+
916
+ /**
917
+ * E1: Adapt a child's UDS `ChildCapDescriptor[]` (which has no `addonId`) into
918
+ * a `RegisterNodeParams` that `onRegisterNode` / `applyNodeManifest` can consume.
919
+ *
920
+ * Strategy: use `childId` as the synthetic `addonId`. For currently-shipped addons
921
+ * `childId = runnerId = addonId` (one-addon-one-process, no shared group), so the
922
+ * `registryKey = childId` produced here matches what the Moleculer `$hub.registerNode`
923
+ * path uses — making double-apply via both paths fully idempotent.
924
+ *
925
+ * Singleton vs. device-scoped caps: `ChildCapDescriptor.deviceId` is present only
926
+ * for device-scoped native caps. The `addons` array carries system (singleton/collection)
927
+ * cap names; device-scoped native caps are handled separately via `nativeCaps` in the
928
+ * full `RegisterNodeParams`. For the parallel-window phase (E1), we populate only
929
+ * the `addons` portion — the Moleculer path carries `nativeCaps` on the re-handshake.
930
+ *
931
+ * TODO(co-location): This function synthesises ONE manifest entry with `addonId = childId`
932
+ * (the runner id). This is correct under the current one-addon-one-process invariant
933
+ * where `childId = runnerId = addonId`. If `execution.group` co-location is ever
934
+ * activated (multiple addons sharing one runner), a single runner would host multiple
935
+ * addonIds but this function would register all their caps under one synthetic addonId —
936
+ * collapsing distinct provider registryKeys into one and breaking per-addon routing.
937
+ * Multi-addon manifest support (splitting the `ChildCapDescriptor[]` by addonId once the
938
+ * protocol carries addonId) would be needed here before enabling co-location post-Phase-F.
939
+ */
940
+ export function buildChildUdsManifest(
941
+ nodeId: string,
942
+ childId: string,
943
+ caps: readonly ChildCapDescriptor[],
944
+ ): RegisterNodeParams {
945
+ // Collect unique system (non-device-scoped) cap names.
946
+ const systemCapNames = new Set<string>()
947
+ for (const cap of caps) {
948
+ if (cap.deviceId === undefined) {
949
+ systemCapNames.add(cap.capName)
950
+ }
611
951
  }
952
+ const addons: readonly RegisteredAddonManifest[] = [
953
+ { addonId: childId, capabilities: [...systemCapNames] },
954
+ ]
955
+ return { nodeId, addons }
612
956
  }
package/src/main.ts CHANGED
@@ -201,6 +201,7 @@ async function bootstrap() {
201
201
  const uploadAddonBridge = app.get(AddonBridgeService);
202
202
  const uploadMoleculer = app.get(MoleculerService);
203
203
  const uploadAddonRegistry = app.get(AddonRegistryService);
204
+ const uploadAddonPackage = app.get(AddonPackageService);
204
205
  const uploadLogger = app.get(LoggingService).createLogger("addon-upload");
205
206
  await registerAddonUploadRoute(
206
207
  fastify,
@@ -208,6 +209,7 @@ async function bootstrap() {
208
209
  uploadAuthService,
209
210
  uploadMoleculer,
210
211
  uploadAddonRegistry,
212
+ uploadAddonPackage,
211
213
  uploadLogger,
212
214
  );
213
215
  console.log(
@@ -907,11 +909,16 @@ async function bootstrap() {
907
909
  const indexPath = path.join(staticDir, "index.html");
908
910
  if (fs.existsSync(staticDir) && fs.existsSync(indexPath)) {
909
911
  spaIndexHtml = indexPath;
912
+ // `serve: false` registers no route — it only decorates
913
+ // `reply.sendFile`, so the single SPA `/*` handler below owns all
914
+ // routing and serves each asset LIVE from the current `staticDir`.
915
+ // The old `wildcard: false` registered one route per file enumerated
916
+ // AT BOOT, so a redeployed admin-ui's new content-hashed assets had no
917
+ // route and 404'd until a hub restart. Live `sendFile` removes that.
910
918
  await fastify.register(fastifyStatic, {
911
919
  root: staticDir,
912
- prefix: "/",
913
- wildcard: false,
914
- decorateReply: false,
920
+ serve: false,
921
+ decorateReply: true,
915
922
  });
916
923
  // Dev diagnostic: serve webrtc-test.html from dataPath if it exists.
917
924
  const webrtcTestPath = path.join(dataPath, "webrtc-test.html");
@@ -921,8 +928,9 @@ async function bootstrap() {
921
928
  });
922
929
  }
923
930
 
924
- // SPA fallback: catch-all route that serves index.html for non-API paths.
925
- // Uses a wildcard route instead of setNotFoundHandler.
931
+ // SPA fallback + live static serving: this single catch-all owns every
932
+ // GET. Core API prefixes fall through to their own routers via
933
+ // `callNotFound`. Uses a wildcard route instead of setNotFoundHandler.
926
934
  fastify.get("/*", async (request, reply) => {
927
935
  const url = request.url;
928
936
  if (
@@ -933,17 +941,42 @@ async function bootstrap() {
933
941
  ) {
934
942
  return reply.callNotFound();
935
943
  }
936
- // A request whose last path segment has a file extension is a
937
- // static asset, not a SPA navigation route. If it reached here,
938
- // `fastify-static` has no route for it the file is missing.
939
- // Return 404 instead of the SPA `index.html`: serving HTML under
940
- // an asset URL makes upstream caches (Cloudflare, the browser)
941
- // pin `text/html` for a `.js`/`.css` URL, which then fails the
942
- // module MIME check long after the file is actually available.
944
+ // A request whose last path segment has a file extension is a static
945
+ // asset: serve it LIVE from the current dist so a redeployed
946
+ // admin-ui's new content-hashed files are picked up without a hub
947
+ // restart. When the file is missing, 404 never the SPA
948
+ // `index.html`: serving HTML under a `.js`/`.css` URL makes upstream
949
+ // caches (Cloudflare, the browser) pin `text/html`, which then fails
950
+ // the module MIME check long after the file is actually available.
943
951
  const pathOnly = url.split("?")[0] ?? url;
944
952
  if (/\.[a-zA-Z0-9]+$/.test(pathOnly)) {
953
+ const rel = pathOnly.replace(/^\/+/, "");
954
+ const abs = path.join(staticDir, rel);
955
+ if (
956
+ (abs === staticDir || abs.startsWith(staticDir + path.sep)) &&
957
+ fs.existsSync(abs)
958
+ ) {
959
+ // Cache policy that lets PWA updates actually propagate (the
960
+ // stale-bundle bug): the service worker + registration + manifest
961
+ // MUST be revalidated every load or a redeploy never reaches the
962
+ // client (the SW keeps serving the old precache). Content-hashed
963
+ // build assets (assets/index-<hash>.js) are immutable. Everything
964
+ // else gets a short cache.
965
+ const base = rel.split("/").pop() ?? rel;
966
+ if (/^(sw\.js|registerSW\.js|workbox-.*\.js|manifest\.webmanifest)$/.test(base)) {
967
+ reply.header("cache-control", "no-cache, must-revalidate");
968
+ } else if (rel.startsWith("assets/") && /-[A-Za-z0-9_-]{8,}\./.test(base)) {
969
+ reply.header("cache-control", "public, max-age=31536000, immutable");
970
+ } else {
971
+ reply.header("cache-control", "no-cache");
972
+ }
973
+ return reply.sendFile(rel);
974
+ }
945
975
  return reply.callNotFound();
946
976
  }
977
+ // index.html (the SPA shell) must never be cached — it references the
978
+ // content-hashed bundles, so a stale copy pins the old app forever.
979
+ reply.header("cache-control", "no-cache, must-revalidate");
947
980
  return reply.type("text/html").send(fs.createReadStream(spaIndexHtml!));
948
981
  });
949
982
  const { version } = await adminUI.getVersion();