@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.
- package/package.json +1 -1
- 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-bulk-update.spec.ts +388 -0
- package/src/__tests__/cap-route-adapter.spec.ts +289 -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__/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 +123 -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/bulk-update-coordinator.ts +302 -0
- package/src/api/core/cap-providers.ts +59 -6
- package/src/api/core/capabilities.router.ts +26 -3
- package/src/api/oauth2/oauth2-routes.ts +5 -1
- package/src/api/trpc/__tests__/client-ip.spec.ts +120 -0
- package/src/api/trpc/cap-route-error-formatter.ts +163 -0
- package/src/api/trpc/client-ip.ts +130 -0
- package/src/api/trpc/generated-cap-mounts.ts +19 -1
- package/src/api/trpc/generated-cap-routers.ts +180 -1
- package/src/api/trpc/trpc.middleware.ts +5 -1
- package/src/api/trpc/trpc.router.ts +45 -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 +364 -105
- package/src/core/addon/addon-settings-provider.ts +40 -116
- package/src/core/capability/capability.service.ts +9 -0
- 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 +380 -36
- 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
|
-
|
|
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
|
-
*
|
|
310
|
-
*
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
670
|
+
capActionName(addonId, capName, method, false),
|
|
462
671
|
serializeTypedArrays(methodParams),
|
|
463
|
-
{ nodeID:
|
|
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
|
|
576
|
-
* reachable on that node
|
|
577
|
-
*
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
913
|
-
|
|
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
|
|
925
|
-
//
|
|
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
|
-
//
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
// pin `text/html
|
|
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();
|