@camstack/server 0.1.6 → 0.1.8
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/package.json +3 -3
- package/src/__tests__/addon-upload.spec.ts +58 -0
- package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
- package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +186 -0
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +243 -0
- package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
- package/src/__tests__/cap-route-adapter.spec.ts +289 -0
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +169 -0
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
- package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
- package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
- package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +329 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
- package/src/__tests__/native-cap-route.spec.ts +404 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
- package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
- package/src/__tests__/uds-log-ingest.spec.ts +183 -0
- package/src/api/addon-upload.ts +27 -1
- package/src/api/capabilities.router.ts +1 -1
- package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
- package/src/api/core/bulk-update-coordinator.ts +302 -0
- package/src/api/core/cap-providers.ts +211 -9
- package/src/api/core/capabilities.router.ts +26 -3
- package/src/api/core/logs.router.ts +4 -0
- package/src/api/oauth2/oauth2-routes.ts +5 -1
- package/src/api/trpc/__tests__/client-ip.spec.ts +146 -0
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +128 -0
- package/src/api/trpc/cap-mount-helpers.ts +12 -1
- package/src/api/trpc/cap-route-error-formatter.ts +163 -0
- package/src/api/trpc/client-ip.ts +147 -0
- package/src/api/trpc/generated-cap-mounts.ts +299 -8
- package/src/api/trpc/generated-cap-routers.ts +2384 -302
- package/src/api/trpc/trpc.middleware.ts +5 -1
- package/src/api/trpc/trpc.router.ts +84 -3
- package/src/boot/__tests__/integration-id-backfill.spec.ts +116 -0
- package/src/boot/integration-id-backfill.ts +109 -0
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
- package/src/core/addon/addon-call-gateway.ts +157 -0
- package/src/core/addon/addon-package.service.ts +9 -0
- package/src/core/addon/addon-registry.service.ts +453 -107
- package/src/core/addon/addon-row-manifest.ts +29 -0
- package/src/core/addon/addon-settings-provider.ts +40 -116
- package/src/core/capability/capability.service.ts +9 -0
- package/src/core/logging/logging.service.ts +7 -2
- package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
- package/src/core/moleculer/cap-call-fn.ts +103 -0
- package/src/core/moleculer/cap-route-authority.ts +182 -0
- package/src/core/moleculer/moleculer.service.ts +408 -36
- package/src/core/network/network-quality.service.spec.ts +2 -1
- package/src/main.ts +137 -12
- package/src/core/storage/settings-store.spec.ts +0 -213
- package/src/core/storage/settings-store.ts +0 -2
- package/src/core/storage/sql-schema.spec.ts +0 -140
- package/src/core/storage/sql-schema.ts +0 -3
|
@@ -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,104 @@ export class MoleculerService {
|
|
|
193
233
|
this.brokerSafe.createService(hubService)
|
|
194
234
|
|
|
195
235
|
const dataDir = this.config.get<string>('dataDir') ?? 'camstack-data'
|
|
196
|
-
|
|
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
|
+
// Single capability authority — lets the broker fallback pin a
|
|
261
|
+
// device-scoped call to its owning node instead of load-balancing it.
|
|
262
|
+
nodeRegistry: this.nodeRegistry,
|
|
263
|
+
// Hub-local UDS child dispatcher — routes a device-scoped native cap
|
|
264
|
+
// owned by a hub-local child (reolink/hikvision cameras) directly over
|
|
265
|
+
// UDS before any broker fallback. Getter: `this.localChildRegistry` is
|
|
266
|
+
// assigned later in this method, after the handler is constructed.
|
|
267
|
+
getLocalDispatcher: () => this.localChildRegistry,
|
|
268
|
+
logger: {
|
|
269
|
+
warn: (msg, meta) => logger.warn(msg, meta !== null && meta !== undefined ? { meta: meta as Record<string, unknown> } : undefined),
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
const registry = new LocalChildRegistry({
|
|
273
|
+
server: createLocalTransport().createServer(nodeId),
|
|
274
|
+
onUnownedCall,
|
|
275
|
+
logger: {
|
|
276
|
+
info: (msg, meta) => logger.info(msg, meta !== null && meta !== undefined ? { meta: meta as Record<string, unknown> } : undefined),
|
|
277
|
+
},
|
|
278
|
+
// Hand the UDS-routing layer a view into the operator's
|
|
279
|
+
// active-singleton preference. Without this, when two local
|
|
280
|
+
// children own the same singleton cap (today: `webrtc-session`
|
|
281
|
+
// → `stream-broker` + `addon-webrtc-native`), routing returns
|
|
282
|
+
// the first-registered child by insertion order — silently
|
|
283
|
+
// bypassing `setActiveSingleton`. The closure reads the live
|
|
284
|
+
// registry on every call so a runtime swap takes effect
|
|
285
|
+
// immediately without rebuilding the resolver snapshot.
|
|
286
|
+
getActiveSingletonAddonId: (capName: string): string | null =>
|
|
287
|
+
this.capabilityService.getRegistry()?.getSingletonAddonId(capName) ?? null,
|
|
288
|
+
})
|
|
289
|
+
await registry.start()
|
|
290
|
+
// E1: apply child manifest + cleanup from the UDS lifecycle (hub-local children).
|
|
291
|
+
// When a runner connects over UDS, apply its cap descriptors to the
|
|
292
|
+
// CapabilityRegistry — same effect as the `$hub.registerNode` RPC for
|
|
293
|
+
// `hub/<runner>` nodes. This runs in PARALLEL with the RPC path until
|
|
294
|
+
// Phase F removes the child broker; `onRegisterNode`'s diff logic ensures
|
|
295
|
+
// double-apply is idempotent (same nodeId + same caps → no-op on the second call).
|
|
296
|
+
registry.onChildRegistered((child) => {
|
|
297
|
+
const hubNodeId = this.brokerSafe.nodeID
|
|
298
|
+
const nodeId = `${hubNodeId}/${child.childId}`
|
|
299
|
+
const params = buildChildUdsManifest(nodeId, child.childId, child.caps)
|
|
300
|
+
this.onRegisterNode(params)
|
|
301
|
+
logger.info('UDS child registered — manifest applied', { meta: { nodeId } })
|
|
302
|
+
})
|
|
303
|
+
// E1: cleanup on child disconnect — same effect as `$node.disconnected`
|
|
304
|
+
// for hub-local children. The Moleculer path stays for AGENT nodes.
|
|
305
|
+
registry.onChildGone((childId) => {
|
|
306
|
+
const hubNodeId = this.brokerSafe.nodeID
|
|
307
|
+
const nodeId = `${hubNodeId}/${childId}`
|
|
308
|
+
logger.info('UDS child gone — removing from registry', { meta: { childId } })
|
|
309
|
+
this.removeNodeFromRegistry(nodeId)
|
|
310
|
+
})
|
|
311
|
+
// B2: ingest UDS child logs into the hub's LoggingService so they appear
|
|
312
|
+
// in the LogManager / admin-UI log stream alongside broker-forwarded logs.
|
|
313
|
+
// This runs in PARALLEL with the existing $hub.log / onLog broker path —
|
|
314
|
+
// both stay active until Phase F removes the broker path.
|
|
315
|
+
registry.onChildLog((childId, entry) => {
|
|
316
|
+
this.logging.writeFromWorker(udsChildLogToWorkerEntry(childId, entry))
|
|
317
|
+
})
|
|
318
|
+
// D1: answer readiness-snapshot requests from UDS children so they can
|
|
319
|
+
// hydrate without a `$readiness.getSnapshot` Moleculer call.
|
|
320
|
+
// `this.readinessRegistry` is the hub-authoritative instance subscribed
|
|
321
|
+
// to the shared EventBusService — same source `$readiness.getSnapshot` uses.
|
|
322
|
+
// The handler is a live closure (calls `getSnapshotForTransport()` on each
|
|
323
|
+
// request) so children always receive the current snapshot, not a stale copy.
|
|
324
|
+
// Keep `$readiness.getSnapshot` intact — Phase F removes it.
|
|
325
|
+
registry.onReadinessSnapshotRequest(() => this.readinessRegistry.getSnapshotForTransport())
|
|
326
|
+
this.localChildRegistry = registry
|
|
327
|
+
parentUdsPath = localEndpointPath(nodeId)
|
|
328
|
+
logger.info('UDS child registry listening', { meta: { path: parentUdsPath } })
|
|
329
|
+
} catch (err) {
|
|
330
|
+
logger.warn('UDS child registry failed to start; children stay broker-only', { meta: { err: err instanceof Error ? err.message : String(err) } })
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const processService: unknown = createProcessService(this.brokerSafe.nodeID, dataDir, undefined, undefined, parentUdsPath)
|
|
197
334
|
this.brokerSafe.createService(processService)
|
|
198
335
|
|
|
199
336
|
// $addonHost — REMOVED (Sprint 6). Three-level settings are now
|
|
@@ -241,6 +378,23 @@ export class MoleculerService {
|
|
|
241
378
|
await this.brokerSafe.start()
|
|
242
379
|
logger.info('Moleculer broker started (TCP transport)')
|
|
243
380
|
|
|
381
|
+
// Construct the CapRouteResolver now that both the broker and
|
|
382
|
+
// localChildRegistry are ready. The resolver reads live registry state
|
|
383
|
+
// via closure accessors on every call (not a frozen snapshot), so new
|
|
384
|
+
// children connecting/disconnecting after this point are picked up
|
|
385
|
+
// correctly. The localChildRegistry reference is captured and may be
|
|
386
|
+
// null if UDS failed to start.
|
|
387
|
+
this.resolver = new CapRouteResolver({
|
|
388
|
+
hubNodeId: this.brokerSafe.nodeID,
|
|
389
|
+
broker: this.brokerSafe,
|
|
390
|
+
hubLocalRegistry: this.localChildRegistry,
|
|
391
|
+
nodeAuthority: createNodeCapAuthority(this.nodeRegistry, {
|
|
392
|
+
resolveSingleton: (capName, nodeId) =>
|
|
393
|
+
this.capabilityService.getRegistry()?.resolveSingletonAddonIdForNode(capName, nodeId) ?? null,
|
|
394
|
+
}),
|
|
395
|
+
inProcessProviders: createInProcessProviderLookup(this.capabilityService),
|
|
396
|
+
})
|
|
397
|
+
|
|
244
398
|
// Wire the hub's EventBusService into the broker so hub-addon
|
|
245
399
|
// emissions fan out to every remote process via
|
|
246
400
|
// `camstack.evt.<category>`, and incoming $event-bus events land on
|
|
@@ -250,6 +404,21 @@ export class MoleculerService {
|
|
|
250
404
|
// duplicate delivery when `createBrokerEventBus` on a remote uses
|
|
251
405
|
// both broadcast + `$hub.event` for back-compat.
|
|
252
406
|
this.eventBus.attachBroker(this.broker)
|
|
407
|
+
|
|
408
|
+
// C2: wire the UDS ↔ Moleculer event bridge so events emitted by UDS
|
|
409
|
+
// children fan to siblings and reach the cluster, and cluster / parent-
|
|
410
|
+
// local events propagate to every UDS child. Inert when no children
|
|
411
|
+
// are connected (bridge just adds a no-op bus subscriber). The bridge
|
|
412
|
+
// is wired after attachBroker so the parentBus is backed by the real
|
|
413
|
+
// shared broker bus and broker.broadcast is live.
|
|
414
|
+
if (this.localChildRegistry !== null) {
|
|
415
|
+
const hubNodeId = this.brokerSafe.nodeID
|
|
416
|
+
this.udsEventBridgeDispose = createUdsEventBridge({
|
|
417
|
+
registry: this.localChildRegistry,
|
|
418
|
+
parentBus: this.eventBus,
|
|
419
|
+
parentNodeId: hubNodeId,
|
|
420
|
+
})
|
|
421
|
+
}
|
|
253
422
|
}
|
|
254
423
|
|
|
255
424
|
/**
|
|
@@ -306,8 +475,20 @@ export class MoleculerService {
|
|
|
306
475
|
|
|
307
476
|
/**
|
|
308
477
|
* Call a capability method on a specific node.
|
|
309
|
-
*
|
|
310
|
-
*
|
|
478
|
+
*
|
|
479
|
+
* Delegates all routing decisions to the CapRouteResolver, which classifies
|
|
480
|
+
* the (capName, nodeId) pair into a typed CapRoute and dispatches to the
|
|
481
|
+
* appropriate transport (hub-in-process, hub-local-uds, remote-moleculer,
|
|
482
|
+
* agent-child-forward). A genuinely-absent cap throws CapRouteError (typed,
|
|
483
|
+
* with reason + rejected routes) instead of the old opaque error string.
|
|
484
|
+
*
|
|
485
|
+
* Falls back to the legacy findCallFn path when the resolver is not yet
|
|
486
|
+
* constructed (before onModuleInit completes) or when the resolver throws a
|
|
487
|
+
* no-provider / node-offline error for a node that IS in nodeCallFns — this
|
|
488
|
+
* handles the window between registerNode applying a callFn and the resolver
|
|
489
|
+
* seeing the new node (the resolver reads live registry state via closure
|
|
490
|
+
* accessors, but nodeCallFns is populated by applyNodeManifest which may
|
|
491
|
+
* have run before the resolver was constructed).
|
|
311
492
|
*/
|
|
312
493
|
async callCapabilityOnNode(
|
|
313
494
|
nodeId: string,
|
|
@@ -315,34 +496,59 @@ export class MoleculerService {
|
|
|
315
496
|
methodName: string,
|
|
316
497
|
params: unknown,
|
|
317
498
|
): Promise<unknown> {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
499
|
+
const resolver = this.resolver
|
|
500
|
+
if (resolver !== null) {
|
|
501
|
+
// Extract deviceId from params so device-scoped native caps (ptz, motion-zones, …)
|
|
502
|
+
// resolve through the resolver's deviceId-aware snapshot instead of falling back to
|
|
503
|
+
// the legacy callFn store. The deviceId hint is a number extracted from the method args.
|
|
504
|
+
const rawDeviceId: unknown =
|
|
505
|
+
params !== null && typeof params === 'object' ? Reflect.get(params, 'deviceId') : undefined
|
|
506
|
+
const routeDeviceId: number | undefined = typeof rawDeviceId === 'number' ? rawDeviceId : undefined
|
|
507
|
+
try {
|
|
508
|
+
const route = resolver.resolveCapRoute(capabilityName, { nodeId, deviceId: routeDeviceId })
|
|
509
|
+
return await resolver.dispatch(route, methodName, params)
|
|
510
|
+
} catch (err) {
|
|
511
|
+
if (err instanceof CapRouteError && (err.reason === 'no-provider' || err.reason === 'node-offline')) {
|
|
512
|
+
// Resolver couldn't find the cap — try the legacy callFn store as a
|
|
513
|
+
// fallback. This covers caps registered in nodeCallFns (e.g. agent
|
|
514
|
+
// nodes that registered before the resolver's snapshot was built or
|
|
515
|
+
// caps that the resolver's nodeAuthority doesn't see yet because the
|
|
516
|
+
// resolver reads live registry state via closure accessors).
|
|
517
|
+
// Device-scoped native caps now resolve via the resolver (M1/M5 thread deviceId),
|
|
518
|
+
// so this fallback only handles genuinely-transitional stale-snapshot windows.
|
|
519
|
+
const callFn = this.findCallFn(nodeId, capabilityName)
|
|
520
|
+
if (callFn !== undefined) {
|
|
521
|
+
return callFn(methodName, params)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// Rethrow — includes transport-failed and all other errors
|
|
525
|
+
throw err
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Pre-init fallback (resolver not yet constructed — before onModuleInit).
|
|
530
|
+
// This path is only reachable in tests that drive onRegisterNode without
|
|
531
|
+
// calling onModuleInit first.
|
|
322
532
|
if (nodeId === 'hub' || nodeId === this.brokerSafe.nodeID) {
|
|
323
533
|
const registry = this.capabilityService.getRegistry()
|
|
324
|
-
const provider = registry?.getSingleton
|
|
325
|
-
if (provider) {
|
|
534
|
+
const provider = registry?.getSingleton<Record<string, unknown>>(capabilityName) ?? null
|
|
535
|
+
if (provider !== null) {
|
|
326
536
|
const fn = provider[methodName]
|
|
327
537
|
if (typeof fn !== 'function') throw new Error(`Method "${methodName}" not found on "${capabilityName}"`)
|
|
328
|
-
return
|
|
538
|
+
return fn.call(provider, params)
|
|
329
539
|
}
|
|
330
|
-
// Fall through to the prefix-fallback findCallFn below.
|
|
331
540
|
}
|
|
332
541
|
|
|
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
542
|
const callFn = this.findCallFn(nodeId, capabilityName)
|
|
341
543
|
if (callFn) {
|
|
342
544
|
return callFn(methodName, params)
|
|
343
545
|
}
|
|
344
546
|
|
|
345
|
-
throw new
|
|
547
|
+
throw new CapRouteError(capabilityName, methodName, {
|
|
548
|
+
reason: 'no-provider',
|
|
549
|
+
nodeId,
|
|
550
|
+
rejected: [{ kind: 'hub-in-process', why: 'CapRouteResolver not initialised (pre-onModuleInit)' }],
|
|
551
|
+
})
|
|
346
552
|
}
|
|
347
553
|
|
|
348
554
|
/**
|
|
@@ -450,18 +656,46 @@ export class MoleculerService {
|
|
|
450
656
|
|
|
451
657
|
const registryKey = registryKeyFor(addonId)
|
|
452
658
|
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
659
|
+
// The runner id (= UDS childId) for a hub-local forked child is the
|
|
660
|
+
// trailing segment of its nodeId `${hubNodeId}/${runnerId}`. Only
|
|
661
|
+
// hub-local children are reachable over UDS; agent-hosted providers
|
|
662
|
+
// (`<agent>/<runner>`) fall through to Moleculer.
|
|
663
|
+
const udsChildId = isLocalChild ? nodeId.slice(hubNodeId.length + 1) : null
|
|
664
|
+
|
|
665
|
+
// Per-(cap,node) dispatcher. Routing lives in the unit-tested
|
|
666
|
+
// `buildCapCallFn` (see cap-call-fn.ts):
|
|
667
|
+
// - hub-local child → per-child UDS (collection-safe; keyed by runner
|
|
668
|
+
// id, never by capName which would collapse a COLLECTION cap onto
|
|
669
|
+
// the first child). Fails fast if the child isn't providing — NEVER
|
|
670
|
+
// a Moleculer fallback, since a hub-local child is not a Moleculer
|
|
671
|
+
// service (a broker call would hang the full discovery timeout).
|
|
672
|
+
// - agent-hosted / remote → the unified `CapRouteResolver`, which
|
|
673
|
+
// classifies an agent node as `agent-child-forward` (hub→agent→UDS
|
|
674
|
+
// child) and a direct remote as `remote-moleculer`. This closes the
|
|
675
|
+
// UDS-migration gap where this dispatcher hand-rolled a `broker.call`
|
|
676
|
+
// to an agent that exposes no Moleculer service for the cap.
|
|
677
|
+
// - resolver not yet built (pre-init) → legacy Moleculer call.
|
|
678
|
+
const callFn: CallFn = buildCapCallFn({
|
|
679
|
+
capName,
|
|
680
|
+
nodeId,
|
|
681
|
+
udsChildId,
|
|
682
|
+
getLocalChildRegistry: () => this.localChildRegistry,
|
|
683
|
+
getResolver: () => this.resolver,
|
|
684
|
+
legacyBrokerCall: (method, methodParams, targetNode) =>
|
|
458
685
|
callWithServiceDiscovery(
|
|
459
686
|
this.brokerSafe,
|
|
460
687
|
addonId,
|
|
461
|
-
|
|
688
|
+
capActionName(addonId, capName, method, false),
|
|
462
689
|
serializeTypedArrays(methodParams),
|
|
463
|
-
{ nodeID:
|
|
464
|
-
)
|
|
690
|
+
{ nodeID: targetNode, timeout: 60_000 },
|
|
691
|
+
),
|
|
692
|
+
onUdsRoute: (cap) => {
|
|
693
|
+
if (!this.udsRoutedCaps.has(cap)) {
|
|
694
|
+
this.udsRoutedCaps.add(cap)
|
|
695
|
+
this.logger.info('routing cap over UDS', { meta: { capName: cap } })
|
|
696
|
+
}
|
|
697
|
+
},
|
|
698
|
+
})
|
|
465
699
|
|
|
466
700
|
const proxy: Record<string, unknown> = { id: addonId, nodeId }
|
|
467
701
|
for (const methodName of Object.keys(expandCapMethods(capDef))) {
|
|
@@ -469,6 +703,23 @@ export class MoleculerService {
|
|
|
469
703
|
}
|
|
470
704
|
registry.registerProvider(capName, registryKey, proxy)
|
|
471
705
|
|
|
706
|
+
// Local-first singleton preference (UDS regression fix). A
|
|
707
|
+
// `placement: 'any-node'` singleton (e.g. `pipeline-executor`) can
|
|
708
|
+
// register on BOTH the hub-local forked child and a remote agent.
|
|
709
|
+
// `CapabilityRegistry` keeps the FIRST-registered provider active, so a
|
|
710
|
+
// race could leave the REMOTE agent proxy active — and its callFn routes
|
|
711
|
+
// over Moleculer to a UDS-only agent runner that no longer hosts the
|
|
712
|
+
// Moleculer service ("not found on <agent>"). The hub-local provider is
|
|
713
|
+
// reachable over UDS, so prefer it whenever the current active is absent
|
|
714
|
+
// or remote (`@`-keyed). Never steals from another local provider, so an
|
|
715
|
+
// operator's binding choice (a bare-key local provider) is preserved.
|
|
716
|
+
if (capDef.mode === 'singleton' && isLocalChild) {
|
|
717
|
+
const activeKey = registry.getSingletonAddonId(capName)
|
|
718
|
+
if (activeKey === null || activeKey.includes('@')) {
|
|
719
|
+
registry.setSingletonActiveAddon(capName, registryKey)
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
472
723
|
// Emit AddonPageReady / AddonWidgetReady so the admin-UI sidebar
|
|
473
724
|
// refreshes its page/widget registry for cross-process addons.
|
|
474
725
|
if (capName === 'addon-pages-source') {
|
|
@@ -570,19 +821,60 @@ export class MoleculerService {
|
|
|
570
821
|
}
|
|
571
822
|
|
|
572
823
|
|
|
824
|
+
/**
|
|
825
|
+
* Returns true when a (nodeId, capabilityName) pair is reachable via the
|
|
826
|
+
* legacy fallback paths: either a stored callFn in nodeCallFns, or a
|
|
827
|
+
* hub-local forked child that is reachable over UDS (even without a
|
|
828
|
+
* manifest callFn — e.g. device-scoped native caps). Used by
|
|
829
|
+
* createCapabilityProxy to decide whether to build a proxy when the
|
|
830
|
+
* CapRouteResolver cannot find a route.
|
|
831
|
+
*/
|
|
832
|
+
private isReachableViaLegacy(nodeId: string, capabilityName: string): boolean {
|
|
833
|
+
if (this.findCallFn(nodeId, capabilityName) !== undefined) return true
|
|
834
|
+
return this.localChildRegistry !== null && nodeId.startsWith(`${this.brokerSafe.nodeID}/`)
|
|
835
|
+
}
|
|
836
|
+
|
|
573
837
|
/**
|
|
574
838
|
* Build a proxy object that forwards every method call on a capability
|
|
575
|
-
* to
|
|
576
|
-
* reachable on that node
|
|
577
|
-
*
|
|
839
|
+
* to the correct transport via CapRouteResolver. Returns null if the
|
|
840
|
+
* capability is provably not reachable on that node (resolver says no-provider
|
|
841
|
+
* AND no legacy callFn exists AND it is not a hub-local child).
|
|
842
|
+
*
|
|
843
|
+
* Used by the generated cap routers when a request includes a `nodeId` field
|
|
844
|
+
* for transparent node routing.
|
|
845
|
+
*
|
|
846
|
+
* Hub-local forked children (e.g. `hub/provider-reolink`) are reachable over
|
|
847
|
+
* UDS even when no manifest callFn exists — device-scoped NATIVE caps (ptz,
|
|
848
|
+
* motion-zones) aren't in `applyNodeManifest`'s callFn store. The proxy is
|
|
849
|
+
* built unconditionally for hub-local children so `callCapabilityOnNode` can
|
|
850
|
+
* route the actual method call via the resolver's hub-local-uds branch.
|
|
578
851
|
*/
|
|
579
852
|
createCapabilityProxy(capabilityName: string, nodeId: string): Record<string, (params: unknown) => Promise<unknown>> | null {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
853
|
+
const resolver = this.resolver
|
|
854
|
+
if (resolver !== null) {
|
|
855
|
+
// Use the resolver to determine reachability. If it resolves a route, we
|
|
856
|
+
// can build a proxy. If it throws no-provider but a legacy callFn exists
|
|
857
|
+
// or the node is a hub-local child (for native caps), build the proxy anyway
|
|
858
|
+
// because callCapabilityOnNode's fallback will handle it at dispatch time.
|
|
859
|
+
try {
|
|
860
|
+
resolver.resolveCapRoute(capabilityName, { nodeId })
|
|
861
|
+
// Resolver found a route — proxy is reachable.
|
|
862
|
+
} catch (err) {
|
|
863
|
+
if (err instanceof CapRouteError && (err.reason === 'no-provider' || err.reason === 'node-offline')) {
|
|
864
|
+
// Check legacy callFn store and hub-local child fallbacks.
|
|
865
|
+
if (!this.isReachableViaLegacy(nodeId, capabilityName)) return null
|
|
866
|
+
// Proxy reachable via fallback paths — fall through to build it.
|
|
867
|
+
} else {
|
|
868
|
+
throw err
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
} else {
|
|
872
|
+
// Pre-init: use legacy reachability check.
|
|
873
|
+
if (!this.isReachableViaLegacy(nodeId, capabilityName)) return null
|
|
874
|
+
}
|
|
583
875
|
|
|
584
876
|
// Build a dynamic proxy: every property access returns a function that
|
|
585
|
-
//
|
|
877
|
+
// routes the call through callCapabilityOnNode (which delegates to the resolver).
|
|
586
878
|
return new Proxy<Record<string, (params: unknown) => Promise<unknown>>>({}, {
|
|
587
879
|
get: (_target, methodName: string) => {
|
|
588
880
|
return (params: unknown): Promise<unknown> =>
|
|
@@ -606,7 +898,87 @@ export class MoleculerService {
|
|
|
606
898
|
return this.nodeRegistry.listNativeCapEntries()
|
|
607
899
|
}
|
|
608
900
|
|
|
901
|
+
/**
|
|
902
|
+
* Per-device slice of {@link listClusterNativeCaps}, served from the
|
|
903
|
+
* registry's `deviceId → entries` index — O(caps-for-device). Used by the
|
|
904
|
+
* per-device `getBindings` hot path so `getAllBindings` doesn't flatten the
|
|
905
|
+
* whole cluster once per device.
|
|
906
|
+
*/
|
|
907
|
+
listClusterNativeCapsForDevice(deviceId: number): readonly import('@camstack/kernel').NodeNativeCapEntry[] {
|
|
908
|
+
return this.nodeRegistry.listNativeCapEntriesForDevice(deviceId)
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* E2: Send a `set-log-level` UDS message to a hub-local child identified by
|
|
913
|
+
* `nodeId` (e.g. `hub/provider-reolink`). Extracts the `childId` from the
|
|
914
|
+
* nodeId and delegates to `LocalChildRegistry.setChildLogLevel`.
|
|
915
|
+
*
|
|
916
|
+
* Returns `true` only if the nodeId is a hub-local child (`hub/<childId}`) AND
|
|
917
|
+
* the child is currently connected to the LocalChildRegistry (the UDS message
|
|
918
|
+
* was emitted). Returns `false` when the nodeId is not a hub-local child, the
|
|
919
|
+
* registry is absent, or the child is not yet/no longer connected — in all
|
|
920
|
+
* three cases the caller (setProcessLogLevel in cap-providers.ts) falls back
|
|
921
|
+
* to the Moleculer `$node-mgmt.setLogLevel` action.
|
|
922
|
+
*/
|
|
923
|
+
setChildLogLevelByNodeId(nodeId: string, level: string): boolean {
|
|
924
|
+
const hubNodeId = this.brokerSafe.nodeID
|
|
925
|
+
if (!nodeId.startsWith(`${hubNodeId}/`)) return false
|
|
926
|
+
const childId = nodeId.slice(hubNodeId.length + 1)
|
|
927
|
+
const registry = this.localChildRegistry
|
|
928
|
+
if (registry === null) return false
|
|
929
|
+
return registry.setChildLogLevel(childId, level)
|
|
930
|
+
}
|
|
931
|
+
|
|
609
932
|
async onModuleDestroy(): Promise<void> {
|
|
933
|
+
this.udsEventBridgeDispose?.()
|
|
934
|
+
this.udsEventBridgeDispose = null
|
|
610
935
|
await this.brokerSafe.stop()
|
|
936
|
+
await this.localChildRegistry?.close()
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// ---------------------------------------------------------------------------
|
|
941
|
+
// Module-level helpers
|
|
942
|
+
// ---------------------------------------------------------------------------
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* E1: Adapt a child's UDS `ChildCapDescriptor[]` (which has no `addonId`) into
|
|
946
|
+
* a `RegisterNodeParams` that `onRegisterNode` / `applyNodeManifest` can consume.
|
|
947
|
+
*
|
|
948
|
+
* Strategy: use `childId` as the synthetic `addonId`. For currently-shipped addons
|
|
949
|
+
* `childId = runnerId = addonId` (one-addon-one-process, no shared group), so the
|
|
950
|
+
* `registryKey = childId` produced here matches what the Moleculer `$hub.registerNode`
|
|
951
|
+
* path uses — making double-apply via both paths fully idempotent.
|
|
952
|
+
*
|
|
953
|
+
* Singleton vs. device-scoped caps: `ChildCapDescriptor.deviceId` is present only
|
|
954
|
+
* for device-scoped native caps. The `addons` array carries system (singleton/collection)
|
|
955
|
+
* cap names; device-scoped native caps are handled separately via `nativeCaps` in the
|
|
956
|
+
* full `RegisterNodeParams`. For the parallel-window phase (E1), we populate only
|
|
957
|
+
* the `addons` portion — the Moleculer path carries `nativeCaps` on the re-handshake.
|
|
958
|
+
*
|
|
959
|
+
* TODO(co-location): This function synthesises ONE manifest entry with `addonId = childId`
|
|
960
|
+
* (the runner id). This is correct under the current one-addon-one-process invariant
|
|
961
|
+
* where `childId = runnerId = addonId`. If `execution.group` co-location is ever
|
|
962
|
+
* activated (multiple addons sharing one runner), a single runner would host multiple
|
|
963
|
+
* addonIds but this function would register all their caps under one synthetic addonId —
|
|
964
|
+
* collapsing distinct provider registryKeys into one and breaking per-addon routing.
|
|
965
|
+
* Multi-addon manifest support (splitting the `ChildCapDescriptor[]` by addonId once the
|
|
966
|
+
* protocol carries addonId) would be needed here before enabling co-location post-Phase-F.
|
|
967
|
+
*/
|
|
968
|
+
export function buildChildUdsManifest(
|
|
969
|
+
nodeId: string,
|
|
970
|
+
childId: string,
|
|
971
|
+
caps: readonly ChildCapDescriptor[],
|
|
972
|
+
): RegisterNodeParams {
|
|
973
|
+
// Collect unique system (non-device-scoped) cap names.
|
|
974
|
+
const systemCapNames = new Set<string>()
|
|
975
|
+
for (const cap of caps) {
|
|
976
|
+
if (cap.deviceId === undefined) {
|
|
977
|
+
systemCapNames.add(cap.capName)
|
|
978
|
+
}
|
|
611
979
|
}
|
|
980
|
+
const addons: readonly RegisteredAddonManifest[] = [
|
|
981
|
+
{ addonId: childId, capabilities: [...systemCapNames] },
|
|
982
|
+
]
|
|
983
|
+
return { nodeId, addons }
|
|
612
984
|
}
|
|
@@ -32,10 +32,11 @@ describe('NetworkQualityService', () => {
|
|
|
32
32
|
})
|
|
33
33
|
|
|
34
34
|
it('should track client stats', () => {
|
|
35
|
-
service.reportClientStats(1, { rttMs: 50, jitterMs: 5, estimatedBandwidthKbps: 20000 })
|
|
35
|
+
service.reportClientStats(1, { rttMs: 50, jitterMs: 5, estimatedBandwidthKbps: 20000, packetLossPercent: 3 })
|
|
36
36
|
const stats = service.getDeviceStats(1)
|
|
37
37
|
expect(stats!.client?.rttMs).toBe(50)
|
|
38
38
|
expect(stats!.client?.estimatedBandwidthKbps).toBe(20000)
|
|
39
|
+
expect(stats!.client?.packetLossPercent).toBe(3)
|
|
39
40
|
})
|
|
40
41
|
|
|
41
42
|
it('should list all device stats', () => {
|