@camstack/server 0.1.7 → 0.2.0

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 (135) hide show
  1. package/package.json +11 -9
  2. package/src/__tests__/addon-install-e2e.test.ts +0 -1
  3. package/src/__tests__/addon-pages-e2e.test.ts +40 -18
  4. package/src/__tests__/addon-settings-router.spec.ts +6 -1
  5. package/src/__tests__/addon-upload.spec.ts +91 -29
  6. package/src/__tests__/agent-registry.spec.ts +26 -9
  7. package/src/__tests__/agent-status-page.spec.ts +1 -3
  8. package/src/__tests__/auth-session-cookie.test.ts +28 -1
  9. package/src/__tests__/bulk-update-coordinator.spec.ts +48 -31
  10. package/src/__tests__/cap-ownership-authority.spec.ts +39 -8
  11. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +206 -0
  12. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +17 -3
  13. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +57 -11
  14. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +292 -0
  15. package/src/__tests__/cap-providers-bulk-update.spec.ts +27 -7
  16. package/src/__tests__/cap-route-adapter.spec.ts +28 -15
  17. package/src/__tests__/cap-routers/_meta.spec.ts +6 -7
  18. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +19 -10
  19. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +177 -0
  20. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +3 -1
  21. package/src/__tests__/cap-routers/capabilities-node.spec.ts +18 -5
  22. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +137 -0
  23. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +72 -20
  24. package/src/__tests__/cap-routers/harness.ts +11 -7
  25. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +17 -3
  26. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +5 -7
  27. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +35 -11
  28. package/src/__tests__/cap-routers/settings-store.router.spec.ts +59 -15
  29. package/src/__tests__/capability-e2e.test.ts +9 -11
  30. package/src/__tests__/cli-e2e.test.ts +80 -59
  31. package/src/__tests__/core-cap-bridge.spec.ts +3 -1
  32. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +12 -2
  33. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +61 -30
  34. package/src/__tests__/embedded-deps-e2e.test.ts +35 -19
  35. package/src/__tests__/event-bus-proxy-router.spec.ts +3 -0
  36. package/src/__tests__/framework-allowlist.spec.ts +5 -4
  37. package/src/__tests__/https-e2e.test.ts +12 -6
  38. package/src/__tests__/lifecycle-e2e.test.ts +60 -11
  39. package/src/__tests__/live-events-subscription.spec.ts +17 -18
  40. package/src/__tests__/moleculer/uds-readiness.spec.ts +11 -4
  41. package/src/__tests__/moleculer/uds-topology.spec.ts +39 -11
  42. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +265 -5
  43. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +16 -7
  44. package/src/__tests__/native-cap-route.spec.ts +42 -19
  45. package/src/__tests__/oauth2-account-linking.spec.ts +63 -17
  46. package/src/__tests__/singleton-contention.test.ts +23 -11
  47. package/src/__tests__/streaming-diagnostic.test.ts +156 -53
  48. package/src/__tests__/streaming-scale.test.ts +69 -35
  49. package/src/__tests__/uds-addon-call-wiring.spec.ts +6 -1
  50. package/src/agent-status-page.ts +4 -3
  51. package/src/api/__tests__/addons-custom.spec.ts +22 -8
  52. package/src/api/__tests__/capabilities.router.test.ts +18 -9
  53. package/src/api/addon-upload.ts +46 -15
  54. package/src/api/addons-custom.router.ts +7 -6
  55. package/src/api/auth-whoami.ts +3 -1
  56. package/src/api/bridge-addons.router.ts +3 -1
  57. package/src/api/capabilities.router.ts +117 -78
  58. package/src/api/core/__tests__/auth-router-totp.spec.ts +57 -16
  59. package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
  60. package/src/api/core/addon-settings.router.ts +4 -1
  61. package/src/api/core/agents.router.ts +52 -53
  62. package/src/api/core/auth.router.ts +55 -36
  63. package/src/api/core/bulk-update-coordinator.ts +25 -22
  64. package/src/api/core/cap-providers.ts +459 -166
  65. package/src/api/core/capabilities.router.ts +30 -23
  66. package/src/api/core/hwaccel.router.ts +37 -10
  67. package/src/api/core/live-events.router.ts +16 -9
  68. package/src/api/core/logs.router.ts +58 -25
  69. package/src/api/core/notifications.router.ts +2 -1
  70. package/src/api/core/repl.router.ts +1 -3
  71. package/src/api/core/settings-backend.router.ts +68 -70
  72. package/src/api/core/system-events.router.ts +41 -32
  73. package/src/api/health/health.routes.ts +7 -13
  74. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
  75. package/src/api/oauth2/consent-page.ts +4 -3
  76. package/src/api/oauth2/oauth2-routes.ts +41 -12
  77. package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
  78. package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
  79. package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
  80. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +136 -0
  81. package/src/api/trpc/cap-mount-helpers.ts +64 -44
  82. package/src/api/trpc/cap-route-error-formatter.ts +17 -9
  83. package/src/api/trpc/client-ip.ts +17 -0
  84. package/src/api/trpc/core-cap-bridge.ts +3 -1
  85. package/src/api/trpc/generated-cap-mounts.ts +801 -286
  86. package/src/api/trpc/generated-cap-routers.ts +5723 -719
  87. package/src/api/trpc/scope-access.ts +7 -7
  88. package/src/api/trpc/trpc.context.ts +7 -4
  89. package/src/api/trpc/trpc.middleware.ts +4 -2
  90. package/src/api/trpc/trpc.router.ts +117 -48
  91. package/src/auth/session-cookie.ts +10 -0
  92. package/src/boot/__tests__/integration-id-backfill.spec.ts +131 -0
  93. package/src/boot/boot-config.ts +103 -122
  94. package/src/boot/integration-id-backfill.ts +109 -0
  95. package/src/boot/post-boot.service.ts +5 -3
  96. package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
  97. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
  98. package/src/core/addon/addon-call-gateway.ts +20 -6
  99. package/src/core/addon/addon-package.service.ts +183 -89
  100. package/src/core/addon/addon-registry.service.ts +1212 -1267
  101. package/src/core/addon/addon-row-manifest.ts +29 -0
  102. package/src/core/addon/addon-search.service.ts +2 -1
  103. package/src/core/addon/addon-settings-provider.ts +27 -7
  104. package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
  105. package/src/core/addon-pages/addon-pages.service.ts +3 -1
  106. package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
  107. package/src/core/agent/agent-registry.service.ts +60 -38
  108. package/src/core/auth/auth.service.spec.ts +6 -8
  109. package/src/core/config/config.service.spec.ts +1 -1
  110. package/src/core/events/event-bus.service.spec.ts +44 -21
  111. package/src/core/events/event-bus.service.ts +5 -1
  112. package/src/core/feature/feature.service.spec.ts +4 -1
  113. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
  114. package/src/core/logging/logging.service.spec.ts +61 -21
  115. package/src/core/logging/logging.service.ts +19 -5
  116. package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
  117. package/src/core/moleculer/cap-call-fn.ts +5 -1
  118. package/src/core/moleculer/cap-route-authority.ts +18 -6
  119. package/src/core/moleculer/moleculer.service.ts +145 -29
  120. package/src/core/network/network-quality.service.spec.ts +7 -1
  121. package/src/core/notification/notification-wrapper.service.ts +1 -3
  122. package/src/core/notification/toast-wrapper.service.ts +1 -5
  123. package/src/core/repl/repl-engine.service.spec.ts +66 -39
  124. package/src/core/repl/repl-engine.service.ts +11 -12
  125. package/src/core/storage/storage-location-manager.spec.ts +12 -3
  126. package/src/core/streaming/stream-probe.service.ts +22 -13
  127. package/src/core/topology/topology-emitter.service.ts +5 -1
  128. package/src/launcher.ts +14 -9
  129. package/src/main.ts +658 -495
  130. package/src/manual-boot.ts +133 -154
  131. package/tsconfig.json +20 -8
  132. package/src/core/storage/settings-store.spec.ts +0 -213
  133. package/src/core/storage/settings-store.ts +0 -2
  134. package/src/core/storage/sql-schema.spec.ts +0 -140
  135. package/src/core/storage/sql-schema.ts +0 -3
@@ -1,4 +1,3 @@
1
-
2
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
3
2
  import * as fs from 'node:fs'
4
3
  import * as path from 'node:path'
@@ -25,19 +24,43 @@ class InMemorySettingsStore implements ISettingsStore {
25
24
  this.system = { ...seed }
26
25
  }
27
26
 
28
- getSystem(key: string): unknown { return this.system[key] }
29
- setSystem(key: string, value: unknown): void { this.system[key] = value }
30
- getAllSystem(): Record<string, unknown> { return { ...this.system } }
31
-
32
- getAllAddon(_addonId: string): Record<string, unknown> { return {} }
33
- setAllAddon(_addonId: string, _config: Record<string, unknown>): void { /* no-op */ }
34
- getAllProvider(_providerId: string): Record<string, unknown> { return {} }
35
- setProvider(_providerId: string, _key: string, _value: unknown): void { /* no-op */ }
36
- getAllDevice(_deviceId: string): Record<string, unknown> { return {} }
37
- setDevice(_deviceId: string, _key: string, _value: unknown): void { /* no-op */ }
38
- getAddonDevice(_addonId: string, _deviceId: string): Record<string, unknown> { return {} }
39
- setAddonDevice(_addonId: string, _deviceId: string, _values: Record<string, unknown>): void { /* no-op */ }
40
- clearAddonDevice(_addonId: string, _deviceId: string): void { /* no-op */ }
27
+ getSystem(key: string): unknown {
28
+ return this.system[key]
29
+ }
30
+ setSystem(key: string, value: unknown): void {
31
+ this.system[key] = value
32
+ }
33
+ getAllSystem(): Record<string, unknown> {
34
+ return { ...this.system }
35
+ }
36
+
37
+ getAllAddon(_addonId: string): Record<string, unknown> {
38
+ return {}
39
+ }
40
+ setAllAddon(_addonId: string, _config: Record<string, unknown>): void {
41
+ /* no-op */
42
+ }
43
+ getAllProvider(_providerId: string): Record<string, unknown> {
44
+ return {}
45
+ }
46
+ setProvider(_providerId: string, _key: string, _value: unknown): void {
47
+ /* no-op */
48
+ }
49
+ getAllDevice(_deviceId: string): Record<string, unknown> {
50
+ return {}
51
+ }
52
+ setDevice(_deviceId: string, _key: string, _value: unknown): void {
53
+ /* no-op */
54
+ }
55
+ getAddonDevice(_addonId: string, _deviceId: string): Record<string, unknown> {
56
+ return {}
57
+ }
58
+ setAddonDevice(_addonId: string, _deviceId: string, _values: Record<string, unknown>): void {
59
+ /* no-op */
60
+ }
61
+ clearAddonDevice(_addonId: string, _deviceId: string): void {
62
+ /* no-op */
63
+ }
41
64
  }
42
65
 
43
66
  describe('ScopedLogger', () => {
@@ -147,13 +170,28 @@ describe('LogRingBuffer', () => {
147
170
  it('filters by tags (addonId exact match)', () => {
148
171
  const buffer = new LogRingBuffer(100)
149
172
 
150
- buffer.push({ timestamp: new Date(), level: 'info', message: 'a', tags: { addonId: 'stream-broker' } })
151
- buffer.push({ timestamp: new Date(), level: 'info', message: 'b', tags: { addonId: 'provider-rtsp' } })
152
- buffer.push({ timestamp: new Date(), level: 'info', message: 'c', tags: { addonId: 'stream-broker' } })
173
+ buffer.push({
174
+ timestamp: new Date(),
175
+ level: 'info',
176
+ message: 'a',
177
+ tags: { addonId: 'stream-broker' },
178
+ })
179
+ buffer.push({
180
+ timestamp: new Date(),
181
+ level: 'info',
182
+ message: 'b',
183
+ tags: { addonId: 'provider-rtsp' },
184
+ })
185
+ buffer.push({
186
+ timestamp: new Date(),
187
+ level: 'info',
188
+ message: 'c',
189
+ tags: { addonId: 'stream-broker' },
190
+ })
153
191
 
154
192
  const result = buffer.query({ tags: { addonId: 'stream-broker' } })
155
193
  expect(result).toHaveLength(2)
156
- expect(result.map((e) => e.message).sort()).toEqual(['a', 'c'])
194
+ expect(result.map((e) => e.message).toSorted()).toEqual(['a', 'c'])
157
195
  })
158
196
 
159
197
  it('respects limit', () => {
@@ -190,9 +228,11 @@ describe('LoggingService', () => {
190
228
  'utf-8',
191
229
  )
192
230
  const configService = new ConfigService(configPath)
193
- configService.setSettingsStore(new InMemorySettingsStore({
194
- 'eventBus.ringBufferSize': bufferSize,
195
- }))
231
+ configService.setSettingsStore(
232
+ new InMemorySettingsStore({
233
+ 'eventBus.ringBufferSize': bufferSize,
234
+ }),
235
+ )
196
236
  return new LoggingService(configService)
197
237
  }
198
238
 
@@ -25,8 +25,13 @@ export class LoggingService extends LogManager {
25
25
  private readonly deviceNames = new Map<number, string>()
26
26
 
27
27
  constructor(configService: ConfigService) {
28
- const bufferSize = configService.get<number>('eventBus.ringBufferSize') ?? 10000
29
- super(bufferSize)
28
+ // The log buffer is now partitioned per addonId (see PartitionedLogBuffer):
29
+ // this value caps EACH addon's bucket, not the total. A chatty addon evicts
30
+ // only its own lines, so quiet addons (e.g. a HomeAssistant `image` entity)
31
+ // keep their sparse history. `eventBus.ringBufferSize` still sizes the
32
+ // separate system-event ring; logs get their own per-addon cap.
33
+ const perAddonCapacity = configService.get<number>('eventBus.perAddonLogBufferSize') ?? 5000
34
+ super(perAddonCapacity)
30
35
  // Enriches every emitted LogEntry with `tags.deviceName` before
31
36
  // destinations / subscribers see it — works across bundled copies
32
37
  // of `@camstack/core` (addon packages) because the mutation
@@ -38,7 +43,12 @@ export class LoggingService extends LogManager {
38
43
  setDeviceNames(entries: ReadonlyArray<{ id: number; name: string }>): void {
39
44
  this.deviceNames.clear()
40
45
  for (const { id, name } of entries) {
41
- if (typeof id === 'number' && Number.isFinite(id) && typeof name === 'string' && name.length > 0) {
46
+ if (
47
+ typeof id === 'number' &&
48
+ Number.isFinite(id) &&
49
+ typeof name === 'string' &&
50
+ name.length > 0
51
+ ) {
42
52
  this.deviceNames.set(id, name)
43
53
  }
44
54
  }
@@ -68,7 +78,11 @@ export class LoggingService extends LogManager {
68
78
  if (data && typeof data.deviceId === 'number') {
69
79
  this.upsertDeviceName(data.deviceId, data.name)
70
80
  selfLogger.info('device-name cache upserted', {
71
- meta: { deviceId: data.deviceId, name: data.name ?? null, cacheSize: this.deviceNames.size },
81
+ meta: {
82
+ deviceId: data.deviceId,
83
+ name: data.name ?? null,
84
+ cacheSize: this.deviceNames.size,
85
+ },
72
86
  })
73
87
  }
74
88
  })
@@ -112,7 +126,7 @@ export class LoggingService extends LogManager {
112
126
  const nodeId = entry.nodeId
113
127
  const agentId = nodeId?.includes('/') ? nodeId.split('/')[0]! : nodeId
114
128
  const mergedTags: LogTags = {
115
- ...(entry.tags ?? {}),
129
+ ...entry.tags,
116
130
  addonId: entry.addonId,
117
131
  ...(nodeId !== undefined ? { nodeId } : {}),
118
132
  ...(agentId !== undefined ? { agentId } : {}),
@@ -1,10 +1,6 @@
1
1
  import { describe, it, expect, vi } from 'vitest'
2
2
  import type { CapRoute, CapCallInput } from '@camstack/kernel'
3
- import {
4
- buildCapCallFn,
5
- type CapCallFnLocalChild,
6
- type CapCallFnResolver,
7
- } from './cap-call-fn.js'
3
+ import { buildCapCallFn, type CapCallFnLocalChild, type CapCallFnResolver } from './cap-call-fn.js'
8
4
 
9
5
  /**
10
6
  * buildCapCallFn — the per-(cap,node) dispatcher behind every CapabilityRegistry
@@ -84,7 +80,10 @@ describe('buildCapCallFn', () => {
84
80
 
85
81
  expect(result).toEqual({ from: 'uds' })
86
82
  expect(child.callCapOnChildCalls).toEqual([
87
- { childId: 'benchmark', input: { capName: 'cap-x', method: 'listPages', args: { deviceId: 7 }, deviceId: 7 } },
83
+ {
84
+ childId: 'benchmark',
85
+ input: { capName: 'cap-x', method: 'listPages', args: { deviceId: 7 }, deviceId: 7 },
86
+ },
88
87
  ])
89
88
  expect(resolver.resolveCalls).toEqual([]) // resolver never consulted
90
89
  expect(legacy).not.toHaveBeenCalled()
@@ -125,8 +124,12 @@ describe('buildCapCallFn', () => {
125
124
  const result = await fn('listPages', { deviceId: 3 })
126
125
 
127
126
  expect(result).toEqual({ from: 'resolver' })
128
- expect(resolver.resolveCalls).toEqual([{ capName: 'cap-x', nodeId: 'dev-agent-0', deviceId: 3 }])
129
- expect(resolver.dispatchCalls).toEqual([{ route: REMOTE_ROUTE, method: 'listPages', args: { deviceId: 3 } }])
127
+ expect(resolver.resolveCalls).toEqual([
128
+ { capName: 'cap-x', nodeId: 'dev-agent-0', deviceId: 3 },
129
+ ])
130
+ expect(resolver.dispatchCalls).toEqual([
131
+ { route: REMOTE_ROUTE, method: 'listPages', args: { deviceId: 3 } },
132
+ ])
130
133
  expect(legacy).not.toHaveBeenCalled()
131
134
  })
132
135
 
@@ -144,11 +147,15 @@ describe('buildCapCallFn', () => {
144
147
 
145
148
  await fn('listPages', undefined, 'dev-agent-1')
146
149
 
147
- expect(resolver.resolveCalls).toEqual([{ capName: 'cap-x', nodeId: 'dev-agent-1', deviceId: undefined }])
150
+ expect(resolver.resolveCalls).toEqual([
151
+ { capName: 'cap-x', nodeId: 'dev-agent-1', deviceId: undefined },
152
+ ])
148
153
  })
149
154
 
150
155
  it('resolver not yet built → falls back to the legacy broker call', async () => {
151
- const legacy = vi.fn<(m: string, p: unknown, n: string) => Promise<unknown>>(async () => ({ from: 'legacy' }))
156
+ const legacy = vi.fn<(m: string, p: unknown, n: string) => Promise<unknown>>(async () => ({
157
+ from: 'legacy',
158
+ }))
152
159
  const fn = buildCapCallFn({
153
160
  capName: 'cap-x',
154
161
  nodeId: 'dev-agent-0',
@@ -44,7 +44,11 @@ export interface CapCallFnDeps {
44
44
  /** Live getter for the resolver (`null` before `onModuleInit` builds it). */
45
45
  readonly getResolver: () => CapCallFnResolver | null
46
46
  /** Legacy Moleculer call — used ONLY in the pre-init window (no resolver yet). */
47
- readonly legacyBrokerCall: (method: string, params: unknown, targetNodeId: string) => Promise<unknown>
47
+ readonly legacyBrokerCall: (
48
+ method: string,
49
+ params: unknown,
50
+ targetNodeId: string,
51
+ ) => Promise<unknown>
48
52
  /** Optional diagnostic hook fired the first time this cap routes over UDS. */
49
53
  readonly onUdsRoute?: (capName: string) => void
50
54
  }
@@ -26,14 +26,21 @@ import type { InProcessProviderRef } from '@camstack/kernel'
26
26
  * is required. HubNodeRegistry satisfies this interface structurally.
27
27
  */
28
28
  export interface NodeRegistryLike {
29
- getNodeManifest(nodeId: string): readonly { readonly addonId: string; readonly capabilities: readonly string[] }[] | undefined
29
+ getNodeManifest(
30
+ nodeId: string,
31
+ ): readonly { readonly addonId: string; readonly capabilities: readonly string[] }[] | undefined
30
32
  listNodeIds(): readonly string[]
31
33
  /**
32
34
  * Optional: returns flat (nodeId, addonId, capName, deviceId) native-cap tuples.
33
35
  * When provided, `nodeKnowsCap` and `isNativeCap` also consult native caps so
34
36
  * device-scoped native caps (ptz, motion-zones, …) are visible to the resolver.
35
37
  */
36
- listNativeCapEntries?(): readonly { readonly nodeId: string; readonly addonId: string; readonly capName: string; readonly deviceId: number }[]
38
+ listNativeCapEntries?(): readonly {
39
+ readonly nodeId: string
40
+ readonly addonId: string
41
+ readonly capName: string
42
+ readonly deviceId: number
43
+ }[]
37
44
  }
38
45
 
39
46
  // ---------------------------------------------------------------------------
@@ -87,7 +94,10 @@ export function createNodeCapAuthority(
87
94
  nodeKnowsCap(nodeId: string, capName: string): boolean {
88
95
  // Check system (manifest) caps first
89
96
  const manifest = nodeRegistry.getNodeManifest(nodeId)
90
- if (manifest !== undefined && manifest.some((addon) => addon.capabilities.includes(capName))) {
97
+ if (
98
+ manifest !== undefined &&
99
+ manifest.some((addon) => addon.capabilities.includes(capName))
100
+ ) {
91
101
  return true
92
102
  }
93
103
  // Also check device-scoped native caps — these are NOT in the addon manifest
@@ -139,7 +149,9 @@ export function createNodeCapAuthority(
139
149
  isNativeCap(nodeId: string, capName: string, deviceId?: number): boolean {
140
150
  const nativeEntries = nodeRegistry.listNativeCapEntries?.() ?? []
141
151
  if (deviceId !== undefined) {
142
- return nativeEntries.some((n) => n.nodeId === nodeId && n.capName === capName && n.deviceId === deviceId)
152
+ return nativeEntries.some(
153
+ (n) => n.nodeId === nodeId && n.capName === capName && n.deviceId === deviceId,
154
+ )
143
155
  }
144
156
  return nativeEntries.some((n) => n.nodeId === nodeId && n.capName === capName)
145
157
  },
@@ -163,8 +175,8 @@ export function createInProcessProviderLookup(
163
175
  ): InProcessProviderLookup {
164
176
  return (capName: string): InProcessProviderRef | null => {
165
177
  const provider =
166
- capabilityService.getSingletonForNode?.(capName, 'hub')
167
- ?? capabilityService.getSingleton(capName)
178
+ capabilityService.getSingletonForNode?.(capName, 'hub') ??
179
+ capabilityService.getSingleton(capName)
168
180
  if (provider === null || provider === undefined) return null
169
181
 
170
182
  const ref: InProcessProviderRef = {
@@ -16,8 +16,37 @@ 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, LocalChildRegistry, createLocalTransport, localEndpointPath, CapRouteResolver, CapRouteError, capActionName, udsChildLogToWorkerEntry, createUdsEventBridge, createParentUnownedCallHandler } from '@camstack/kernel'
20
- import type { HubServiceDeps, CallFn, RegisterNodeParams, RegisteredAddonManifest, ChildCapDescriptor } from '@camstack/kernel'
19
+ import {
20
+ createBroker,
21
+ createHubService,
22
+ createProcessService,
23
+ isInfraCapability,
24
+ registerEventBusService,
25
+ createReadinessServiceForRegistry,
26
+ createStreamProbeBrokerService,
27
+ createHwAccelService,
28
+ createKernelHwAccel,
29
+ HubNodeRegistry,
30
+ serializeTypedArrays,
31
+ callWithServiceDiscovery,
32
+ hashClusterSecret,
33
+ LocalChildRegistry,
34
+ createLocalTransport,
35
+ localEndpointPath,
36
+ CapRouteResolver,
37
+ CapRouteError,
38
+ capActionName,
39
+ udsChildLogToWorkerEntry,
40
+ createUdsEventBridge,
41
+ createParentUnownedCallHandler,
42
+ } from '@camstack/kernel'
43
+ import type {
44
+ HubServiceDeps,
45
+ CallFn,
46
+ RegisterNodeParams,
47
+ RegisteredAddonManifest,
48
+ ChildCapDescriptor,
49
+ } from '@camstack/kernel'
21
50
  import { buildCapCallFn } from './cap-call-fn.js'
22
51
  import { createNodeCapAuthority, createInProcessProviderLookup } from './cap-route-authority.js'
23
52
  import { EventCategory, expandCapMethods, ReadinessRegistry, emitReadiness } from '@camstack/types'
@@ -55,7 +84,9 @@ interface AppliedCapEntry {
55
84
  export class MoleculerService {
56
85
  readonly broker: ServiceBroker
57
86
  /** Narrow-typed view of `this.broker` — see `BrokerLike` doc above. */
58
- private get brokerSafe(): BrokerLike { return this.broker as unknown as BrokerLike }
87
+ private get brokerSafe(): BrokerLike {
88
+ return this.broker as unknown as BrokerLike
89
+ }
59
90
  private readonly logger: ReturnType<LoggingService['createLogger']>
60
91
  /**
61
92
  * D3 authority: union of every node's manifest delivered via
@@ -148,7 +179,8 @@ export class MoleculerService {
148
179
  // moleculer→eventemitter2 whose types are unresolvable at this
149
180
  // boundary, so the inference falls to `error` and trips
150
181
  // `no-unsafe-assignment`. Going via `unknown` documents the boundary.
151
- this.clusterSecret = process.env['CAMSTACK_CLUSTER_SECRET'] ?? this.config.get<string>('cluster.secret')
182
+ this.clusterSecret =
183
+ process.env['CAMSTACK_CLUSTER_SECRET'] ?? this.config.get<string>('cluster.secret')
152
184
  const broker = createBroker({
153
185
  nodeID: 'hub',
154
186
  mode: 'hub',
@@ -226,7 +258,9 @@ export class MoleculerService {
226
258
  onUnregisterNode: (nodeId) => {
227
259
  this.removeNodeFromRegistry(nodeId)
228
260
  },
229
- expectedClusterSecretHash: this.clusterSecret ? hashClusterSecret(this.clusterSecret) : undefined,
261
+ expectedClusterSecretHash: this.clusterSecret
262
+ ? hashClusterSecret(this.clusterSecret)
263
+ : undefined,
230
264
  }
231
265
 
232
266
  const hubService: unknown = createHubService(hubDeps)
@@ -257,16 +291,55 @@ export class MoleculerService {
257
291
  getResolver: () => this.resolver,
258
292
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
259
293
  broker: this.broker,
294
+ // Single capability authority — lets the broker fallback pin a
295
+ // device-scoped call to its owning node instead of load-balancing it.
296
+ nodeRegistry: this.nodeRegistry,
297
+ // Hub-local UDS child dispatcher — routes a device-scoped native cap
298
+ // owned by a hub-local child (reolink/hikvision cameras) directly over
299
+ // UDS before any broker fallback. Getter: `this.localChildRegistry` is
300
+ // assigned later in this method, after the handler is constructed.
301
+ getLocalDispatcher: () => this.localChildRegistry,
302
+ // Device-native signal for the registration-race recovery: a forked
303
+ // child's device-native cap (e.g. `switch` on an export target) can be
304
+ // briefly absent from the LocalChildRegistry just after a respawn. The
305
+ // kernel layer has no cap registry, so feed it the `deviceNative` flag
306
+ // from the cap definition. When true + binding-not-yet-registered, the
307
+ // handler retries the hub-local route then throws a precise error rather
308
+ // than the unroutable broker fallback (`switch.switch.getStatus`).
309
+ isDeviceNativeCap: (capName) =>
310
+ this.capabilityService.getRegistry()?.getDefinition(capName)?.deviceNative === true,
260
311
  logger: {
261
- warn: (msg, meta) => logger.warn(msg, meta !== null && meta !== undefined ? { meta: meta as Record<string, unknown> } : undefined),
312
+ warn: (msg, meta) =>
313
+ logger.warn(
314
+ msg,
315
+ meta !== null && meta !== undefined
316
+ ? { meta: meta as Record<string, unknown> }
317
+ : undefined,
318
+ ),
262
319
  },
263
320
  })
264
321
  const registry = new LocalChildRegistry({
265
322
  server: createLocalTransport().createServer(nodeId),
266
323
  onUnownedCall,
267
324
  logger: {
268
- info: (msg, meta) => logger.info(msg, meta !== null && meta !== undefined ? { meta: meta as Record<string, unknown> } : undefined),
325
+ info: (msg, meta) =>
326
+ logger.info(
327
+ msg,
328
+ meta !== null && meta !== undefined
329
+ ? { meta: meta as Record<string, unknown> }
330
+ : undefined,
331
+ ),
269
332
  },
333
+ // Hand the UDS-routing layer a view into the operator's
334
+ // active-singleton preference. Without this, when two local
335
+ // children own the same singleton cap (e.g. two `webrtc-session`
336
+ // providers), routing returns
337
+ // the first-registered child by insertion order — silently
338
+ // bypassing `setActiveSingleton`. The closure reads the live
339
+ // registry on every call so a runtime swap takes effect
340
+ // immediately without rebuilding the resolver snapshot.
341
+ getActiveSingletonAddonId: (capName: string): string | null =>
342
+ this.capabilityService.getRegistry()?.getSingletonAddonId(capName) ?? null,
270
343
  })
271
344
  await registry.start()
272
345
  // E1: apply child manifest + cleanup from the UDS lifecycle (hub-local children).
@@ -309,10 +382,18 @@ export class MoleculerService {
309
382
  parentUdsPath = localEndpointPath(nodeId)
310
383
  logger.info('UDS child registry listening', { meta: { path: parentUdsPath } })
311
384
  } catch (err) {
312
- logger.warn('UDS child registry failed to start; children stay broker-only', { meta: { err: err instanceof Error ? err.message : String(err) } })
385
+ logger.warn('UDS child registry failed to start; children stay broker-only', {
386
+ meta: { err: err instanceof Error ? err.message : String(err) },
387
+ })
313
388
  }
314
389
 
315
- const processService: unknown = createProcessService(this.brokerSafe.nodeID, dataDir, undefined, undefined, parentUdsPath)
390
+ const processService: unknown = createProcessService(
391
+ this.brokerSafe.nodeID,
392
+ dataDir,
393
+ undefined,
394
+ undefined,
395
+ parentUdsPath,
396
+ )
316
397
  this.brokerSafe.createService(processService)
317
398
 
318
399
  // $addonHost — REMOVED (Sprint 6). Three-level settings are now
@@ -345,10 +426,12 @@ export class MoleculerService {
345
426
  // workers). Keeps ffprobe + HTTP-reachability as a single
346
427
  // hub-side implementation; see `createStreamProbeBrokerService`
347
428
  // for the shape.
348
- this.brokerSafe.createService(createStreamProbeBrokerService({
349
- probe: (url, options) => this.streamProbe.probe(url, options),
350
- probeField: (key, value) => this.streamProbe.probeField(key, value),
351
- }))
429
+ this.brokerSafe.createService(
430
+ createStreamProbeBrokerService({
431
+ probe: (url, options) => this.streamProbe.probe(url, options),
432
+ probeField: (key, value) => this.streamProbe.probeField(key, value),
433
+ }),
434
+ )
352
435
 
353
436
  // Register `$hwaccel` on hub — every node in the cluster does the
354
437
  // same so `broker.call('$hwaccel.resolve', params, { nodeID })`
@@ -372,7 +455,8 @@ export class MoleculerService {
372
455
  hubLocalRegistry: this.localChildRegistry,
373
456
  nodeAuthority: createNodeCapAuthority(this.nodeRegistry, {
374
457
  resolveSingleton: (capName, nodeId) =>
375
- this.capabilityService.getRegistry()?.resolveSingletonAddonIdForNode(capName, nodeId) ?? null,
458
+ this.capabilityService.getRegistry()?.resolveSingletonAddonIdForNode(capName, nodeId) ??
459
+ null,
376
460
  }),
377
461
  inProcessProviders: createInProcessProviderLookup(this.capabilityService),
378
462
  })
@@ -485,12 +569,16 @@ export class MoleculerService {
485
569
  // the legacy callFn store. The deviceId hint is a number extracted from the method args.
486
570
  const rawDeviceId: unknown =
487
571
  params !== null && typeof params === 'object' ? Reflect.get(params, 'deviceId') : undefined
488
- const routeDeviceId: number | undefined = typeof rawDeviceId === 'number' ? rawDeviceId : undefined
572
+ const routeDeviceId: number | undefined =
573
+ typeof rawDeviceId === 'number' ? rawDeviceId : undefined
489
574
  try {
490
575
  const route = resolver.resolveCapRoute(capabilityName, { nodeId, deviceId: routeDeviceId })
491
576
  return await resolver.dispatch(route, methodName, params)
492
577
  } catch (err) {
493
- if (err instanceof CapRouteError && (err.reason === 'no-provider' || err.reason === 'node-offline')) {
578
+ if (
579
+ err instanceof CapRouteError &&
580
+ (err.reason === 'no-provider' || err.reason === 'node-offline')
581
+ ) {
494
582
  // Resolver couldn't find the cap — try the legacy callFn store as a
495
583
  // fallback. This covers caps registered in nodeCallFns (e.g. agent
496
584
  // nodes that registered before the resolver's snapshot was built or
@@ -516,7 +604,8 @@ export class MoleculerService {
516
604
  const provider = registry?.getSingleton<Record<string, unknown>>(capabilityName) ?? null
517
605
  if (provider !== null) {
518
606
  const fn = provider[methodName]
519
- if (typeof fn !== 'function') throw new Error(`Method "${methodName}" not found on "${capabilityName}"`)
607
+ if (typeof fn !== 'function')
608
+ throw new Error(`Method "${methodName}" not found on "${capabilityName}"`)
520
609
  return fn.call(provider, params)
521
610
  }
522
611
  }
@@ -529,7 +618,9 @@ export class MoleculerService {
529
618
  throw new CapRouteError(capabilityName, methodName, {
530
619
  reason: 'no-provider',
531
620
  nodeId,
532
- rejected: [{ kind: 'hub-in-process', why: 'CapRouteResolver not initialised (pre-onModuleInit)' }],
621
+ rejected: [
622
+ { kind: 'hub-in-process', why: 'CapRouteResolver not initialised (pre-onModuleInit)' },
623
+ ],
533
624
  })
534
625
  }
535
626
 
@@ -578,7 +669,10 @@ export class MoleculerService {
578
669
  * already; this method handles only `params.addons` (system caps).
579
670
  * Native-cap wiring into device-manager is done in a later task.
580
671
  */
581
- private applyNodeManifest(params: RegisterNodeParams, previousManifest?: readonly RegisteredAddonManifest[]): void {
672
+ private applyNodeManifest(
673
+ params: RegisterNodeParams,
674
+ previousManifest?: readonly RegisteredAddonManifest[],
675
+ ): void {
582
676
  const { nodeId, addons } = params
583
677
  const hubNodeId = this.brokerSafe.nodeID
584
678
  const isLocalChild = nodeId.startsWith(hubNodeId + '/')
@@ -604,7 +698,9 @@ export class MoleculerService {
604
698
  // what is (or would have been) registered. Keyed `${registryKey}::${capName}`.
605
699
  // The resolved `capDef` is carried through so the register loop never
606
700
  // re-looks it up (and never needs a non-null assertion).
607
- const appliedKeys = (manifest: readonly RegisteredAddonManifest[]): Map<string, AppliedCapEntry> => {
701
+ const appliedKeys = (
702
+ manifest: readonly RegisteredAddonManifest[],
703
+ ): Map<string, AppliedCapEntry> => {
608
704
  const keys = new Map<string, AppliedCapEntry>()
609
705
  for (const addon of manifest) {
610
706
  const registryKey = registryKeyFor(addon.addonId)
@@ -736,7 +832,7 @@ export class MoleculerService {
736
832
  */
737
833
  private removeNodeFromRegistry(nodeId: string): void {
738
834
  const manifest = this.nodeRegistry.getNodeManifest(nodeId)
739
- if (!manifest) return // node never sent a handshake — nothing to do
835
+ if (!manifest) return // node never sent a handshake — nothing to do
740
836
 
741
837
  const hubNodeId = this.brokerSafe.nodeID
742
838
  const isLocalChild = nodeId.startsWith(hubNodeId + '/')
@@ -802,7 +898,6 @@ export class MoleculerService {
802
898
  return undefined
803
899
  }
804
900
 
805
-
806
901
  /**
807
902
  * Returns true when a (nodeId, capabilityName) pair is reachable via the
808
903
  * legacy fallback paths: either a stored callFn in nodeCallFns, or a
@@ -831,7 +926,10 @@ export class MoleculerService {
831
926
  * built unconditionally for hub-local children so `callCapabilityOnNode` can
832
927
  * route the actual method call via the resolver's hub-local-uds branch.
833
928
  */
834
- createCapabilityProxy(capabilityName: string, nodeId: string): Record<string, (params: unknown) => Promise<unknown>> | null {
929
+ createCapabilityProxy(
930
+ capabilityName: string,
931
+ nodeId: string,
932
+ ): Record<string, (params: unknown) => Promise<unknown>> | null {
835
933
  const resolver = this.resolver
836
934
  if (resolver !== null) {
837
935
  // Use the resolver to determine reachability. If it resolves a route, we
@@ -842,7 +940,10 @@ export class MoleculerService {
842
940
  resolver.resolveCapRoute(capabilityName, { nodeId })
843
941
  // Resolver found a route — proxy is reachable.
844
942
  } catch (err) {
845
- if (err instanceof CapRouteError && (err.reason === 'no-provider' || err.reason === 'node-offline')) {
943
+ if (
944
+ err instanceof CapRouteError &&
945
+ (err.reason === 'no-provider' || err.reason === 'node-offline')
946
+ ) {
846
947
  // Check legacy callFn store and hub-local child fallbacks.
847
948
  if (!this.isReachableViaLegacy(nodeId, capabilityName)) return null
848
949
  // Proxy reachable via fallback paths — fall through to build it.
@@ -857,12 +958,15 @@ export class MoleculerService {
857
958
 
858
959
  // Build a dynamic proxy: every property access returns a function that
859
960
  // routes the call through callCapabilityOnNode (which delegates to the resolver).
860
- return new Proxy<Record<string, (params: unknown) => Promise<unknown>>>({}, {
861
- get: (_target, methodName: string) => {
862
- return (params: unknown): Promise<unknown> =>
863
- this.callCapabilityOnNode(nodeId, capabilityName, methodName, params)
961
+ return new Proxy<Record<string, (params: unknown) => Promise<unknown>>>(
962
+ {},
963
+ {
964
+ get: (_target, methodName: string) => {
965
+ return (params: unknown): Promise<unknown> =>
966
+ this.callCapabilityOnNode(nodeId, capabilityName, methodName, params)
967
+ },
864
968
  },
865
- })
969
+ )
866
970
  }
867
971
 
868
972
  /**
@@ -880,6 +984,18 @@ export class MoleculerService {
880
984
  return this.nodeRegistry.listNativeCapEntries()
881
985
  }
882
986
 
987
+ /**
988
+ * Per-device slice of {@link listClusterNativeCaps}, served from the
989
+ * registry's `deviceId → entries` index — O(caps-for-device). Used by the
990
+ * per-device `getBindings` hot path so `getAllBindings` doesn't flatten the
991
+ * whole cluster once per device.
992
+ */
993
+ listClusterNativeCapsForDevice(
994
+ deviceId: number,
995
+ ): readonly import('@camstack/kernel').NodeNativeCapEntry[] {
996
+ return this.nodeRegistry.listNativeCapEntriesForDevice(deviceId)
997
+ }
998
+
883
999
  /**
884
1000
  * E2: Send a `set-log-level` UDS message to a hub-local child identified by
885
1001
  * `nodeId` (e.g. `hub/provider-reolink`). Extracts the `childId` from the
@@ -32,10 +32,16 @@ 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, {
36
+ rttMs: 50,
37
+ jitterMs: 5,
38
+ estimatedBandwidthKbps: 20000,
39
+ packetLossPercent: 3,
40
+ })
36
41
  const stats = service.getDeviceStats(1)
37
42
  expect(stats!.client?.rttMs).toBe(50)
38
43
  expect(stats!.client?.estimatedBandwidthKbps).toBe(20000)
44
+ expect(stats!.client?.packetLossPercent).toBe(3)
39
45
  })
40
46
 
41
47
  it('should list all device stats', () => {
@@ -19,9 +19,7 @@ export class NotificationServiceWrapper {
19
19
 
20
20
  get service(): NotificationService {
21
21
  if (!this._service) {
22
- this._service = new NotificationService(
23
- this.logging.createLogger('notifications'),
24
- )
22
+ this._service = new NotificationService(this.logging.createLogger('notifications'))
25
23
  const registry = this.caps.getRegistry()
26
24
  if (registry) {
27
25
  this._service.setRegistry(registry)
@@ -21,11 +21,7 @@ export class ToastServiceWrapper {
21
21
  this._service.sendToUser(userId, toast)
22
22
  }
23
23
 
24
- subscribe(
25
- connectionId: string,
26
- userId: string,
27
- callback: (toast: Toast) => void,
28
- ): () => void {
24
+ subscribe(connectionId: string, userId: string, callback: (toast: Toast) => void): () => void {
29
25
  return this._service.subscribe(connectionId, userId, callback)
30
26
  }
31
27
  }