@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.
- package/package.json +11 -9
- package/src/__tests__/addon-install-e2e.test.ts +0 -1
- package/src/__tests__/addon-pages-e2e.test.ts +40 -18
- package/src/__tests__/addon-settings-router.spec.ts +6 -1
- package/src/__tests__/addon-upload.spec.ts +91 -29
- package/src/__tests__/agent-registry.spec.ts +26 -9
- package/src/__tests__/agent-status-page.spec.ts +1 -3
- package/src/__tests__/auth-session-cookie.test.ts +28 -1
- package/src/__tests__/bulk-update-coordinator.spec.ts +48 -31
- package/src/__tests__/cap-ownership-authority.spec.ts +39 -8
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +206 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +17 -3
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +57 -11
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +292 -0
- package/src/__tests__/cap-providers-bulk-update.spec.ts +27 -7
- package/src/__tests__/cap-route-adapter.spec.ts +28 -15
- package/src/__tests__/cap-routers/_meta.spec.ts +6 -7
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +19 -10
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +177 -0
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +3 -1
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +18 -5
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +137 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +72 -20
- package/src/__tests__/cap-routers/harness.ts +11 -7
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +17 -3
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +5 -7
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +35 -11
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +59 -15
- package/src/__tests__/capability-e2e.test.ts +9 -11
- package/src/__tests__/cli-e2e.test.ts +80 -59
- package/src/__tests__/core-cap-bridge.spec.ts +3 -1
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +12 -2
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +61 -30
- package/src/__tests__/embedded-deps-e2e.test.ts +35 -19
- package/src/__tests__/event-bus-proxy-router.spec.ts +3 -0
- package/src/__tests__/framework-allowlist.spec.ts +5 -4
- package/src/__tests__/https-e2e.test.ts +12 -6
- package/src/__tests__/lifecycle-e2e.test.ts +60 -11
- package/src/__tests__/live-events-subscription.spec.ts +17 -18
- package/src/__tests__/moleculer/uds-readiness.spec.ts +11 -4
- package/src/__tests__/moleculer/uds-topology.spec.ts +39 -11
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +265 -5
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +16 -7
- package/src/__tests__/native-cap-route.spec.ts +42 -19
- package/src/__tests__/oauth2-account-linking.spec.ts +63 -17
- package/src/__tests__/singleton-contention.test.ts +23 -11
- package/src/__tests__/streaming-diagnostic.test.ts +156 -53
- package/src/__tests__/streaming-scale.test.ts +69 -35
- package/src/__tests__/uds-addon-call-wiring.spec.ts +6 -1
- package/src/agent-status-page.ts +4 -3
- package/src/api/__tests__/addons-custom.spec.ts +22 -8
- package/src/api/__tests__/capabilities.router.test.ts +18 -9
- package/src/api/addon-upload.ts +46 -15
- package/src/api/addons-custom.router.ts +7 -6
- package/src/api/auth-whoami.ts +3 -1
- package/src/api/bridge-addons.router.ts +3 -1
- package/src/api/capabilities.router.ts +117 -78
- package/src/api/core/__tests__/auth-router-totp.spec.ts +57 -16
- package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
- package/src/api/core/addon-settings.router.ts +4 -1
- package/src/api/core/agents.router.ts +52 -53
- package/src/api/core/auth.router.ts +55 -36
- package/src/api/core/bulk-update-coordinator.ts +25 -22
- package/src/api/core/cap-providers.ts +459 -166
- package/src/api/core/capabilities.router.ts +30 -23
- package/src/api/core/hwaccel.router.ts +37 -10
- package/src/api/core/live-events.router.ts +16 -9
- package/src/api/core/logs.router.ts +58 -25
- package/src/api/core/notifications.router.ts +2 -1
- package/src/api/core/repl.router.ts +1 -3
- package/src/api/core/settings-backend.router.ts +68 -70
- package/src/api/core/system-events.router.ts +41 -32
- package/src/api/health/health.routes.ts +7 -13
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
- package/src/api/oauth2/consent-page.ts +4 -3
- package/src/api/oauth2/oauth2-routes.ts +41 -12
- package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
- package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +136 -0
- package/src/api/trpc/cap-mount-helpers.ts +64 -44
- package/src/api/trpc/cap-route-error-formatter.ts +17 -9
- package/src/api/trpc/client-ip.ts +17 -0
- package/src/api/trpc/core-cap-bridge.ts +3 -1
- package/src/api/trpc/generated-cap-mounts.ts +801 -286
- package/src/api/trpc/generated-cap-routers.ts +5723 -719
- package/src/api/trpc/scope-access.ts +7 -7
- package/src/api/trpc/trpc.context.ts +7 -4
- package/src/api/trpc/trpc.middleware.ts +4 -2
- package/src/api/trpc/trpc.router.ts +117 -48
- package/src/auth/session-cookie.ts +10 -0
- package/src/boot/__tests__/integration-id-backfill.spec.ts +131 -0
- package/src/boot/boot-config.ts +103 -122
- package/src/boot/integration-id-backfill.ts +109 -0
- package/src/boot/post-boot.service.ts +5 -3
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
- package/src/core/addon/addon-call-gateway.ts +20 -6
- package/src/core/addon/addon-package.service.ts +183 -89
- package/src/core/addon/addon-registry.service.ts +1212 -1267
- package/src/core/addon/addon-row-manifest.ts +29 -0
- package/src/core/addon/addon-search.service.ts +2 -1
- package/src/core/addon/addon-settings-provider.ts +27 -7
- package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
- package/src/core/addon-pages/addon-pages.service.ts +3 -1
- package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
- package/src/core/agent/agent-registry.service.ts +60 -38
- package/src/core/auth/auth.service.spec.ts +6 -8
- package/src/core/config/config.service.spec.ts +1 -1
- package/src/core/events/event-bus.service.spec.ts +44 -21
- package/src/core/events/event-bus.service.ts +5 -1
- package/src/core/feature/feature.service.spec.ts +4 -1
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
- package/src/core/logging/logging.service.spec.ts +61 -21
- package/src/core/logging/logging.service.ts +19 -5
- package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
- package/src/core/moleculer/cap-call-fn.ts +5 -1
- package/src/core/moleculer/cap-route-authority.ts +18 -6
- package/src/core/moleculer/moleculer.service.ts +145 -29
- package/src/core/network/network-quality.service.spec.ts +7 -1
- package/src/core/notification/notification-wrapper.service.ts +1 -3
- package/src/core/notification/toast-wrapper.service.ts +1 -5
- package/src/core/repl/repl-engine.service.spec.ts +66 -39
- package/src/core/repl/repl-engine.service.ts +11 -12
- package/src/core/storage/storage-location-manager.spec.ts +12 -3
- package/src/core/streaming/stream-probe.service.ts +22 -13
- package/src/core/topology/topology-emitter.service.ts +5 -1
- package/src/launcher.ts +14 -9
- package/src/main.ts +658 -495
- package/src/manual-boot.ts +133 -154
- package/tsconfig.json +20 -8
- 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
|
@@ -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 {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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({
|
|
151
|
-
|
|
152
|
-
|
|
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).
|
|
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(
|
|
194
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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 (
|
|
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: {
|
|
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
|
-
...
|
|
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
|
-
{
|
|
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([
|
|
129
|
-
|
|
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([
|
|
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 () => ({
|
|
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: (
|
|
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(
|
|
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 {
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
20
|
-
|
|
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 {
|
|
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 =
|
|
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
|
|
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) =>
|
|
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) =>
|
|
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', {
|
|
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(
|
|
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(
|
|
349
|
-
|
|
350
|
-
|
|
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) ??
|
|
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 =
|
|
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 (
|
|
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')
|
|
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: [
|
|
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(
|
|
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 = (
|
|
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
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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, {
|
|
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
|
}
|