@camstack/server 0.1.3
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/.env.example +17 -0
- package/package.json +55 -0
- package/src/__tests__/addon-install-e2e.test.ts +75 -0
- package/src/__tests__/addon-pages-e2e.test.ts +178 -0
- package/src/__tests__/addon-route-session.test.ts +17 -0
- package/src/__tests__/addon-settings-router.spec.ts +62 -0
- package/src/__tests__/addon-upload.spec.ts +355 -0
- package/src/__tests__/agent-registry.spec.ts +162 -0
- package/src/__tests__/agent-status-page.spec.ts +84 -0
- package/src/__tests__/auth-session-cookie.test.ts +21 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
- package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
- package/src/__tests__/cap-routers/harness.ts +159 -0
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
- package/src/__tests__/capability-e2e.test.ts +386 -0
- package/src/__tests__/cli-e2e.test.ts +129 -0
- package/src/__tests__/core-cap-bridge.spec.ts +89 -0
- package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
- package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
- package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
- package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
- package/src/__tests__/framework-allowlist.spec.ts +95 -0
- package/src/__tests__/https-e2e.test.ts +118 -0
- package/src/__tests__/lifecycle-e2e.test.ts +140 -0
- package/src/__tests__/live-events-subscription.spec.ts +150 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
- package/src/__tests__/post-boot-restart.spec.ts +161 -0
- package/src/__tests__/singleton-contention.test.ts +487 -0
- package/src/__tests__/streaming-diagnostic.test.ts +512 -0
- package/src/__tests__/streaming-scale.test.ts +280 -0
- package/src/agent-status-page.ts +121 -0
- package/src/api/__tests__/addons-custom.spec.ts +134 -0
- package/src/api/__tests__/capabilities.router.test.ts +47 -0
- package/src/api/addon-upload.ts +472 -0
- package/src/api/addons-custom.router.ts +100 -0
- package/src/api/auth-whoami.ts +99 -0
- package/src/api/bridge-addons.router.ts +120 -0
- package/src/api/capabilities.router.ts +226 -0
- package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
- package/src/api/core/addon-settings.router.ts +124 -0
- package/src/api/core/agents.router.ts +87 -0
- package/src/api/core/auth.router.ts +303 -0
- package/src/api/core/cap-providers.ts +993 -0
- package/src/api/core/capabilities.router.ts +119 -0
- package/src/api/core/collection-preference.ts +40 -0
- package/src/api/core/event-bus-proxy.router.ts +45 -0
- package/src/api/core/hwaccel.router.ts +81 -0
- package/src/api/core/live-events.router.ts +60 -0
- package/src/api/core/logs.router.ts +162 -0
- package/src/api/core/notifications.router.ts +65 -0
- package/src/api/core/repl.router.ts +41 -0
- package/src/api/core/settings-backend.router.ts +142 -0
- package/src/api/core/stream-probe.router.ts +57 -0
- package/src/api/core/system-events.router.ts +116 -0
- package/src/api/health/health.routes.ts +123 -0
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
- package/src/api/oauth2/consent-page.ts +42 -0
- package/src/api/oauth2/oauth2-routes.ts +248 -0
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
- package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
- package/src/api/trpc/cap-mount-helpers.ts +225 -0
- package/src/api/trpc/core-cap-bridge.ts +152 -0
- package/src/api/trpc/generated-cap-mounts.ts +707 -0
- package/src/api/trpc/generated-cap-routers.ts +6340 -0
- package/src/api/trpc/scope-access.ts +110 -0
- package/src/api/trpc/trpc.context.ts +255 -0
- package/src/api/trpc/trpc.middleware.ts +140 -0
- package/src/api/trpc/trpc.router.ts +275 -0
- package/src/auth/session-cookie.ts +44 -0
- package/src/boot/boot-config.ts +278 -0
- package/src/boot/post-boot.service.ts +103 -0
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
- package/src/core/addon/addon-package.service.ts +1684 -0
- package/src/core/addon/addon-registry.service.ts +2926 -0
- package/src/core/addon/addon-search.service.ts +90 -0
- package/src/core/addon/addon-settings-provider.ts +276 -0
- package/src/core/addon/addon.tokens.ts +2 -0
- package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
- package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
- package/src/core/addon-pages/addon-pages.service.ts +80 -0
- package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
- package/src/core/agent/agent-registry.service.ts +507 -0
- package/src/core/auth/auth.service.spec.ts +88 -0
- package/src/core/auth/auth.service.ts +8 -0
- package/src/core/capability/capability.service.ts +57 -0
- package/src/core/config/config.schema.ts +3 -0
- package/src/core/config/config.service.spec.ts +175 -0
- package/src/core/config/config.service.ts +7 -0
- package/src/core/events/event-bus.service.spec.ts +212 -0
- package/src/core/events/event-bus.service.ts +85 -0
- package/src/core/feature/feature.service.spec.ts +96 -0
- package/src/core/feature/feature.service.ts +8 -0
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
- package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
- package/src/core/logging/log-ring-buffer.ts +3 -0
- package/src/core/logging/logging.service.spec.ts +247 -0
- package/src/core/logging/logging.service.ts +129 -0
- package/src/core/logging/scoped-logger.ts +3 -0
- package/src/core/moleculer/moleculer.service.ts +612 -0
- package/src/core/network/network-quality.service.spec.ts +47 -0
- package/src/core/network/network-quality.service.ts +5 -0
- package/src/core/notification/notification-wrapper.service.ts +36 -0
- package/src/core/notification/toast-wrapper.service.ts +31 -0
- package/src/core/provider/provider.tokens.ts +1 -0
- package/src/core/repl/repl-engine.service.spec.ts +417 -0
- package/src/core/repl/repl-engine.service.ts +156 -0
- package/src/core/storage/fs-storage-backend.spec.ts +70 -0
- package/src/core/storage/fs-storage-backend.ts +3 -0
- package/src/core/storage/settings-store.spec.ts +213 -0
- package/src/core/storage/settings-store.ts +2 -0
- package/src/core/storage/sql-schema.spec.ts +140 -0
- package/src/core/storage/sql-schema.ts +3 -0
- package/src/core/storage/storage-location-manager.spec.ts +121 -0
- package/src/core/storage/storage-location-manager.ts +3 -0
- package/src/core/storage/storage.service.spec.ts +73 -0
- package/src/core/storage/storage.service.ts +3 -0
- package/src/core/streaming/stream-probe.service.ts +212 -0
- package/src/core/topology/topology-emitter.service.ts +101 -0
- package/src/launcher.ts +309 -0
- package/src/main.ts +1049 -0
- package/src/manual-boot.ts +322 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import { ServiceBroker, type Service, type ServiceSchema } from 'moleculer'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Narrow-typed view of the Moleculer surface this file actually uses.
|
|
5
|
+
* Moleculer's published `.d.ts` chains through `eventemitter2` whose
|
|
6
|
+
* package.json has no `types` field; typescript-eslint's `no-unsafe-*`
|
|
7
|
+
* rules flag every `broker.X` access. Casting once at the boundary
|
|
8
|
+
* collapses the rule violations into one documented narrowing.
|
|
9
|
+
*/
|
|
10
|
+
interface BrokerLike {
|
|
11
|
+
readonly nodeID: string
|
|
12
|
+
readonly logger: Record<string, unknown>
|
|
13
|
+
start(): Promise<void>
|
|
14
|
+
stop(): Promise<void>
|
|
15
|
+
createService(svc: Service | unknown): unknown
|
|
16
|
+
call(action: string, params?: unknown, opts?: unknown): Promise<unknown>
|
|
17
|
+
waitForServices(services: string[], timeout?: number): Promise<unknown>
|
|
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'
|
|
21
|
+
import { EventCategory, expandCapMethods, ReadinessRegistry, emitReadiness } from '@camstack/types'
|
|
22
|
+
import type { CapabilityDefinition } from '@camstack/types'
|
|
23
|
+
import { randomUUID } from 'node:crypto'
|
|
24
|
+
import { EventBusService } from '../events/event-bus.service'
|
|
25
|
+
import { ConfigService } from '../config/config.service'
|
|
26
|
+
import { LoggingService } from '../logging/logging.service'
|
|
27
|
+
import { CapabilityService } from '../capability/capability.service'
|
|
28
|
+
import { StreamProbeService } from '../streaming/stream-probe.service'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Narrow-typed view of the Moleculer broker surface used by the
|
|
32
|
+
* `$node.disconnected` listener — extends `BrokerLike` with `localBus`
|
|
33
|
+
* so the handler can be fully typed (same pattern as agent-registry.service.ts).
|
|
34
|
+
*/
|
|
35
|
+
interface BrokerWithLocalBus {
|
|
36
|
+
localBus: {
|
|
37
|
+
on(event: string, handler: (payload: { node: { id: string } }) => void): void
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One `(addon, capability)` pair that a node manifest applies onto the
|
|
43
|
+
* CapabilityRegistry, with its resolved `CapabilityDefinition`. Built by
|
|
44
|
+
* `applyNodeManifest`'s diff so the register loop never re-resolves the
|
|
45
|
+
* cap def. Keyed in the diff map by `` `${registryKey}::${capName}` ``.
|
|
46
|
+
*/
|
|
47
|
+
interface AppliedCapEntry {
|
|
48
|
+
readonly addonId: string
|
|
49
|
+
readonly capName: string
|
|
50
|
+
readonly capDef: CapabilityDefinition
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class MoleculerService {
|
|
54
|
+
readonly broker: ServiceBroker
|
|
55
|
+
/** Narrow-typed view of `this.broker` — see `BrokerLike` doc above. */
|
|
56
|
+
private get brokerSafe(): BrokerLike { return this.broker as unknown as BrokerLike }
|
|
57
|
+
private readonly logger: ReturnType<LoggingService['createLogger']>
|
|
58
|
+
/**
|
|
59
|
+
* D3 authority: union of every node's manifest delivered via
|
|
60
|
+
* `$hub.registerNode`. Populated by `onRegisterNode` in `hubDeps`.
|
|
61
|
+
*/
|
|
62
|
+
private readonly nodeRegistry = new HubNodeRegistry()
|
|
63
|
+
private readonly nodeCallFns = new Map<string, CallFn>()
|
|
64
|
+
/**
|
|
65
|
+
* D3: callback invoked whenever a bare-ID agent node completes the
|
|
66
|
+
* `$hub.registerNode` handshake. Registered by `AgentRegistryService`
|
|
67
|
+
* via `setOnAgentRegistered()`. The handshake is the authoritative
|
|
68
|
+
* completeness signal — the hub has the full manifest at this point
|
|
69
|
+
* and reconciliation can run immediately (no grace delay needed).
|
|
70
|
+
*/
|
|
71
|
+
private onAgentRegisteredCb: ((nodeId: string) => void) | null = null
|
|
72
|
+
/**
|
|
73
|
+
* Hub-side authoritative readiness registry. Subscribed to the
|
|
74
|
+
* shared `EventBusService` so it ingests both hub-local emits and
|
|
75
|
+
* remote emits forwarded via `$hub.event`. Exposed to:
|
|
76
|
+
* - the `$readiness.getSnapshot` Moleculer action (consumed by
|
|
77
|
+
* workers / agents on boot)
|
|
78
|
+
* - `ctx.kernel.readinessRegistry` on every hub addon context so
|
|
79
|
+
* hub consumers share the same snapshot.
|
|
80
|
+
*/
|
|
81
|
+
readonly readinessRegistry: ReadinessRegistry
|
|
82
|
+
/**
|
|
83
|
+
* Resolved cluster secret (`CAMSTACK_CLUSTER_SECRET` env, else
|
|
84
|
+
* `cluster.secret` config), or `undefined` when none is configured.
|
|
85
|
+
* Threaded both into the broker factory and the hub's
|
|
86
|
+
* `expectedClusterSecretHash` so `$hub.registerNode` can gate on it.
|
|
87
|
+
*/
|
|
88
|
+
private readonly clusterSecret: string | undefined
|
|
89
|
+
|
|
90
|
+
constructor(
|
|
91
|
+
private readonly eventBus: EventBusService,
|
|
92
|
+
private readonly config: ConfigService,
|
|
93
|
+
private readonly logging: LoggingService,
|
|
94
|
+
private readonly capabilityService: CapabilityService,
|
|
95
|
+
private readonly streamProbe: StreamProbeService,
|
|
96
|
+
) {
|
|
97
|
+
this.logger = this.logging.createLogger('moleculer')
|
|
98
|
+
// Optional port overrides. Live primarily for the e2e harness: when
|
|
99
|
+
// a developer's dev:full is up on the default 6000/4445 ports, an
|
|
100
|
+
// isolated test hub can't reuse them. Production keeps the defaults
|
|
101
|
+
// so cluster discovery + agent-to-hub connections keep working
|
|
102
|
+
// without per-deploy config.
|
|
103
|
+
const tcpPortEnv = process.env['CAMSTACK_HUB_TCP_PORT']
|
|
104
|
+
const udpPortEnv = process.env['CAMSTACK_HUB_UDP_PORT']
|
|
105
|
+
const tcpPort = tcpPortEnv ? Number(tcpPortEnv) : undefined
|
|
106
|
+
const udpPort = udpPortEnv ? Number(udpPortEnv) : undefined
|
|
107
|
+
// Two-step cast: createBroker's dist `.d.ts` chains through
|
|
108
|
+
// moleculer→eventemitter2 whose types are unresolvable at this
|
|
109
|
+
// boundary, so the inference falls to `error` and trips
|
|
110
|
+
// `no-unsafe-assignment`. Going via `unknown` documents the boundary.
|
|
111
|
+
this.clusterSecret = process.env['CAMSTACK_CLUSTER_SECRET'] ?? this.config.get<string>('cluster.secret')
|
|
112
|
+
const broker = createBroker({
|
|
113
|
+
nodeID: 'hub',
|
|
114
|
+
mode: 'hub',
|
|
115
|
+
logLevel: this.config.get<string>('moleculer.logLevel') ?? 'warn',
|
|
116
|
+
secret: this.clusterSecret,
|
|
117
|
+
...(tcpPort && !Number.isNaN(tcpPort) ? { tcpPort } : {}),
|
|
118
|
+
...(udpPort && !Number.isNaN(udpPort) ? { udpPort } : {}),
|
|
119
|
+
}) as unknown
|
|
120
|
+
// `ServiceBroker` itself surfaces as `error`-typed at this boundary
|
|
121
|
+
// (eventemitter2 chain unresolvable). Documented + single-site cast.
|
|
122
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
123
|
+
this.broker = broker as ServiceBroker
|
|
124
|
+
this.readinessRegistry = new ReadinessRegistry({
|
|
125
|
+
eventBus: this.eventBus,
|
|
126
|
+
sourceNodeId: this.brokerSafe.nodeID,
|
|
127
|
+
logger: this.logging.createLogger('readiness'),
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* D3: register the callback that fires when an agent node completes the
|
|
133
|
+
* `$hub.registerNode` handshake. Called by `AgentRegistryService` during
|
|
134
|
+
* its own `onModuleInit` — after `MoleculerService.onModuleInit` has
|
|
135
|
+
* returned, so the broker is already live. Option (b) direct-callback
|
|
136
|
+
* wiring: no event-bus round-trip needed for internal core wiring.
|
|
137
|
+
*/
|
|
138
|
+
setOnAgentRegistered(cb: (nodeId: string) => void): void {
|
|
139
|
+
this.onAgentRegisteredCb = cb
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async onModuleInit(): Promise<void> {
|
|
143
|
+
const logger = this.logging.createLogger('moleculer')
|
|
144
|
+
|
|
145
|
+
const hubDeps: HubServiceDeps = {
|
|
146
|
+
getAddonConfig: (addonId) => {
|
|
147
|
+
return this.config.getAddonConfig(addonId)
|
|
148
|
+
},
|
|
149
|
+
getSettings: (scope, key) => {
|
|
150
|
+
return this.config.get(key ? `${scope}.${key}` : scope)
|
|
151
|
+
},
|
|
152
|
+
getRecentEvents: (category, limit) => {
|
|
153
|
+
return this.eventBus.getRecent(category ? { category } : undefined, limit)
|
|
154
|
+
},
|
|
155
|
+
onLog: (entry) => {
|
|
156
|
+
this.logging.writeFromWorker({
|
|
157
|
+
addonId: entry.addonId,
|
|
158
|
+
nodeId: entry.nodeId,
|
|
159
|
+
level: entry.level,
|
|
160
|
+
message: entry.message,
|
|
161
|
+
...(entry.scope !== undefined ? { scope: entry.scope } : {}),
|
|
162
|
+
...(entry.tags ? { tags: entry.tags } : {}),
|
|
163
|
+
...(entry.meta ? { meta: entry.meta } : {}),
|
|
164
|
+
})
|
|
165
|
+
},
|
|
166
|
+
onSetLogLevel: (level) => {
|
|
167
|
+
const factory = this.brokerSafe.logger
|
|
168
|
+
const appenders = factory['appenders'] as Array<{ opts: { level: string } }> | undefined
|
|
169
|
+
if (appenders) {
|
|
170
|
+
for (const appender of appenders) {
|
|
171
|
+
appender.opts.level = level
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const cache = factory['cache'] as Map<string, unknown> | undefined
|
|
175
|
+
if (cache) {
|
|
176
|
+
cache.clear()
|
|
177
|
+
}
|
|
178
|
+
logger.info('Moleculer log level changed', { meta: { level } })
|
|
179
|
+
this.brokerSafe.call('$process.setLogLevel', { level }).catch(() => {})
|
|
180
|
+
},
|
|
181
|
+
// D3: registration-handshake path. Nodes send $hub.registerNode with
|
|
182
|
+
// their complete capability manifest; the hub applies it immediately.
|
|
183
|
+
onRegisterNode: (params) => {
|
|
184
|
+
this.onRegisterNode(params)
|
|
185
|
+
},
|
|
186
|
+
onUnregisterNode: (nodeId) => {
|
|
187
|
+
this.removeNodeFromRegistry(nodeId)
|
|
188
|
+
},
|
|
189
|
+
expectedClusterSecretHash: this.clusterSecret ? hashClusterSecret(this.clusterSecret) : undefined,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const hubService: unknown = createHubService(hubDeps)
|
|
193
|
+
this.brokerSafe.createService(hubService)
|
|
194
|
+
|
|
195
|
+
const dataDir = this.config.get<string>('dataDir') ?? 'camstack-data'
|
|
196
|
+
const processService: unknown = createProcessService(this.brokerSafe.nodeID, dataDir)
|
|
197
|
+
this.brokerSafe.createService(processService)
|
|
198
|
+
|
|
199
|
+
// $addonHost — REMOVED (Sprint 6). Three-level settings are now
|
|
200
|
+
// served by the `addon-settings` singleton capability. Per-addon
|
|
201
|
+
// Moleculer services expose `settings.*` actions for remote agents.
|
|
202
|
+
|
|
203
|
+
// D3: mirror $node.disconnected onto the registry path so nodes that
|
|
204
|
+
// sent a $hub.registerNode manifest get cleaned up on disconnect.
|
|
205
|
+
const bridgeBus = this.broker as unknown as BrokerWithLocalBus
|
|
206
|
+
bridgeBus.localBus.on('$node.disconnected', ({ node }: { node: { id: string } }) => {
|
|
207
|
+
this.removeNodeFromRegistry(node.id)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// Register the $event-bus service BEFORE broker.start(). Moleculer
|
|
211
|
+
// announces service subscriptions to remote nodes only during discovery
|
|
212
|
+
// handshake, not dynamically after post-start `createService()`.
|
|
213
|
+
// Without this, cross-node broadcasts (camstack.evt.<category>) would
|
|
214
|
+
// arrive unreliably for the first ~10s after each node joins.
|
|
215
|
+
registerEventBusService(this.broker)
|
|
216
|
+
|
|
217
|
+
// Register the hub-authoritative `$readiness.getSnapshot` service
|
|
218
|
+
// BEFORE broker.start() so workers / agents see it in their initial
|
|
219
|
+
// INFO packet — post-start `createService` calls propagate via
|
|
220
|
+
// heartbeat (several seconds) and would force workers to poll.
|
|
221
|
+
this.brokerSafe.createService(createReadinessServiceForRegistry(this.readinessRegistry))
|
|
222
|
+
|
|
223
|
+
// Register the hub-authoritative `$stream-probe` service —
|
|
224
|
+
// workers route RTSP probe + field-probe through this action when
|
|
225
|
+
// the tRPC WSS link isn't available (default path for forked
|
|
226
|
+
// workers). Keeps ffprobe + HTTP-reachability as a single
|
|
227
|
+
// hub-side implementation; see `createStreamProbeBrokerService`
|
|
228
|
+
// for the shape.
|
|
229
|
+
this.brokerSafe.createService(createStreamProbeBrokerService({
|
|
230
|
+
probe: (url, options) => this.streamProbe.probe(url, options),
|
|
231
|
+
probeField: (key, value) => this.streamProbe.probeField(key, value),
|
|
232
|
+
}))
|
|
233
|
+
|
|
234
|
+
// Register `$hwaccel` on hub — every node in the cluster does the
|
|
235
|
+
// same so `broker.call('$hwaccel.resolve', params, { nodeID })`
|
|
236
|
+
// returns the backend list for whichever host the caller targets.
|
|
237
|
+
// Admin UI uses this to show per-agent hwaccel info on the
|
|
238
|
+
// pipeline / NodeDetail pages.
|
|
239
|
+
this.brokerSafe.createService(createHwAccelService(createKernelHwAccel()))
|
|
240
|
+
|
|
241
|
+
await this.brokerSafe.start()
|
|
242
|
+
logger.info('Moleculer broker started (TCP transport)')
|
|
243
|
+
|
|
244
|
+
// Wire the hub's EventBusService into the broker so hub-addon
|
|
245
|
+
// emissions fan out to every remote process via
|
|
246
|
+
// `camstack.evt.<category>`, and incoming $event-bus events land on
|
|
247
|
+
// the same local bus that subscribers already use. The
|
|
248
|
+
// EventBusService's `emit` override handles the "only broadcast
|
|
249
|
+
// locally-originated events" guard, and id-based dedup absorbs the
|
|
250
|
+
// duplicate delivery when `createBrokerEventBus` on a remote uses
|
|
251
|
+
// both broadcast + `$hub.event` for back-compat.
|
|
252
|
+
this.eventBus.attachBroker(this.broker)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Register the log-receiver service for agent log forwarding.
|
|
257
|
+
* Must be called AFTER app.init() so Moleculer re-advertises
|
|
258
|
+
* the updated service list to the network.
|
|
259
|
+
*/
|
|
260
|
+
registerLogReceiver(): void {
|
|
261
|
+
this.brokerSafe.createService({
|
|
262
|
+
name: 'log-receiver',
|
|
263
|
+
actions: {
|
|
264
|
+
ingest: {
|
|
265
|
+
handler: (ctx: {
|
|
266
|
+
params: {
|
|
267
|
+
level: string
|
|
268
|
+
message: string
|
|
269
|
+
addonId: string
|
|
270
|
+
nodeId: string
|
|
271
|
+
scope?: string
|
|
272
|
+
tags?: import('@camstack/types').LogTags
|
|
273
|
+
meta?: Record<string, unknown>
|
|
274
|
+
}
|
|
275
|
+
}) => {
|
|
276
|
+
this.logging.writeFromWorker({
|
|
277
|
+
addonId: ctx.params.addonId,
|
|
278
|
+
nodeId: ctx.params.nodeId,
|
|
279
|
+
level: ctx.params.level,
|
|
280
|
+
message: ctx.params.message,
|
|
281
|
+
...(ctx.params.scope !== undefined ? { scope: ctx.params.scope } : {}),
|
|
282
|
+
...(ctx.params.tags ? { tags: ctx.params.tags } : {}),
|
|
283
|
+
...(ctx.params.meta ? { meta: ctx.params.meta } : {}),
|
|
284
|
+
})
|
|
285
|
+
return true
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Register the `$core-caps` Moleculer service that bridges the hub's
|
|
294
|
+
* core (non-addon) tRPC routers onto the cluster mesh.
|
|
295
|
+
*
|
|
296
|
+
* Called from `main.ts` after the appRouter is built — that happens
|
|
297
|
+
* after `app.init()`, so the broker is already started. Post-start
|
|
298
|
+
* `createService` is fine: the service propagates to remote nodes via
|
|
299
|
+
* heartbeat and `brokerTransportLink` polls discovery, so forked
|
|
300
|
+
* addons and late-joining agents still resolve `ctx.api.<coreCap>`.
|
|
301
|
+
* `registerLogReceiver` relies on the same post-init registration.
|
|
302
|
+
*/
|
|
303
|
+
registerCoreCapService(service: ServiceSchema): void {
|
|
304
|
+
this.brokerSafe.createService(service)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Call a capability method on a specific node.
|
|
309
|
+
* Hub: calls the local provider directly (same process, no Moleculer overhead).
|
|
310
|
+
* Remote: routes through the stored Moleculer CallFn for that node.
|
|
311
|
+
*/
|
|
312
|
+
async callCapabilityOnNode(
|
|
313
|
+
nodeId: string,
|
|
314
|
+
capabilityName: string,
|
|
315
|
+
methodName: string,
|
|
316
|
+
params: unknown,
|
|
317
|
+
): Promise<unknown> {
|
|
318
|
+
// Hub — call local provider directly when registered on hub root.
|
|
319
|
+
// For caps hosted in a per-addon runner (e.g. 'hub/detection-pipeline'),
|
|
320
|
+
// the hub root registry has nothing; we then fall through to the
|
|
321
|
+
// remote findCallFn prefix lookup which routes to the subnode.
|
|
322
|
+
if (nodeId === 'hub' || nodeId === this.brokerSafe.nodeID) {
|
|
323
|
+
const registry = this.capabilityService.getRegistry()
|
|
324
|
+
const provider = registry?.getSingleton(capabilityName) as Record<string, unknown> | null
|
|
325
|
+
if (provider) {
|
|
326
|
+
const fn = provider[methodName]
|
|
327
|
+
if (typeof fn !== 'function') throw new Error(`Method "${methodName}" not found on "${capabilityName}"`)
|
|
328
|
+
return (fn as (p: unknown) => unknown).call(provider, params)
|
|
329
|
+
}
|
|
330
|
+
// Fall through to the prefix-fallback findCallFn below.
|
|
331
|
+
}
|
|
332
|
+
|
|
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
|
+
const callFn = this.findCallFn(nodeId, capabilityName)
|
|
341
|
+
if (callFn) {
|
|
342
|
+
return callFn(methodName, params)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
throw new Error(`Capability "${capabilityName}" not available on node "${nodeId}"`)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* D3 registration-handshake entrypoint — invoked by the `$hub.registerNode`
|
|
350
|
+
* Moleculer action (wired through `hubDeps.onRegisterNode`).
|
|
351
|
+
*
|
|
352
|
+
* Captures the node's PREVIOUS manifest before `nodeRegistry.registerNode`
|
|
353
|
+
* overwrites it, then hands both manifests to `applyNodeManifest` so the
|
|
354
|
+
* CapabilityRegistry update is a diff (atomic replace) rather than an
|
|
355
|
+
* unconditional re-register — see `applyNodeManifest` for the rationale.
|
|
356
|
+
*/
|
|
357
|
+
private onRegisterNode(params: RegisterNodeParams): void {
|
|
358
|
+
const previousManifest = this.nodeRegistry.getNodeManifest(params.nodeId)
|
|
359
|
+
this.nodeRegistry.registerNode(params)
|
|
360
|
+
this.applyNodeManifest(params, previousManifest)
|
|
361
|
+
// Notify AgentRegistryService to reconcile placement for bare-ID
|
|
362
|
+
// agent nodes (no '/' = not a hub child worker, not the hub itself).
|
|
363
|
+
// The handshake is the authoritative completeness signal — the full
|
|
364
|
+
// manifest is available here so reconciliation runs without delay.
|
|
365
|
+
const { nodeId } = params
|
|
366
|
+
if (nodeId !== 'hub' && !nodeId.includes('/') && this.onAgentRegisteredCb) {
|
|
367
|
+
this.onAgentRegisteredCb(nodeId)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* D3: apply a node's registered manifest onto the CapabilityRegistry.
|
|
373
|
+
* Builds a method proxy for each capability — same registryKey rule
|
|
374
|
+
* (local child → bare addonId, remote agent → addonId@nodeId), same
|
|
375
|
+
* `broker.call` routing shape, same `expandCapMethods` method surface.
|
|
376
|
+
*
|
|
377
|
+
* Called from `onRegisterNode` whenever a node handshakes.
|
|
378
|
+
*
|
|
379
|
+
* Diff-based / idempotent: the D3 protocol legitimately re-handshakes (a
|
|
380
|
+
* node re-sends its COMPLETE manifest — e.g. the post-device-restore
|
|
381
|
+
* `nativeCaps` re-handshake). `registerProvider` throws on a duplicate
|
|
382
|
+
* `(cap, addonId)` pair, so a blind re-register would throw on every
|
|
383
|
+
* re-handshake and trip the registering node's retry loop into a storm.
|
|
384
|
+
* Instead this diffs the NEW manifest against `previousManifest`:
|
|
385
|
+
* unchanged caps are left untouched, dropped caps are unregistered, new
|
|
386
|
+
* caps are registered. This honours the invariant "`registerNode`
|
|
387
|
+
* replaces the node's entire cap set atomically".
|
|
388
|
+
*
|
|
389
|
+
* NOTE: `params.nativeCaps` is stored by `nodeRegistry.registerNode()`
|
|
390
|
+
* already; this method handles only `params.addons` (system caps).
|
|
391
|
+
* Native-cap wiring into device-manager is done in a later task.
|
|
392
|
+
*/
|
|
393
|
+
private applyNodeManifest(params: RegisterNodeParams, previousManifest?: readonly RegisteredAddonManifest[]): void {
|
|
394
|
+
const { nodeId, addons } = params
|
|
395
|
+
const hubNodeId = this.brokerSafe.nodeID
|
|
396
|
+
const isLocalChild = nodeId.startsWith(hubNodeId + '/')
|
|
397
|
+
const isHubInProcess = nodeId === hubNodeId
|
|
398
|
+
|
|
399
|
+
// Hub in-process addons register themselves during initialize() — skip.
|
|
400
|
+
if (isHubInProcess) return
|
|
401
|
+
|
|
402
|
+
const registry = this.capabilityService.getRegistry()
|
|
403
|
+
if (!registry) return
|
|
404
|
+
|
|
405
|
+
// Same registryKey rule as CapabilityBridge / onProviderConnected:
|
|
406
|
+
// local child → bare addonId (one instance per forked child)
|
|
407
|
+
// remote agent → addonId@nodeId (unique per agent node)
|
|
408
|
+
// `nodeId` is identical for the previous and new manifest (same node
|
|
409
|
+
// re-handshaking), so the rule resolves the same key on both sides.
|
|
410
|
+
const registryKeyFor = (addonId: string): string =>
|
|
411
|
+
isLocalChild ? addonId : `${addonId}@${nodeId}`
|
|
412
|
+
|
|
413
|
+
// Collect the `(registryKey, capName)` pairs a manifest would APPLY —
|
|
414
|
+
// applying the SAME `isInfraCapability` skip and `capDef` existence
|
|
415
|
+
// check the register block below uses, so the set reflects exactly
|
|
416
|
+
// what is (or would have been) registered. Keyed `${registryKey}::${capName}`.
|
|
417
|
+
// The resolved `capDef` is carried through so the register loop never
|
|
418
|
+
// re-looks it up (and never needs a non-null assertion).
|
|
419
|
+
const appliedKeys = (manifest: readonly RegisteredAddonManifest[]): Map<string, AppliedCapEntry> => {
|
|
420
|
+
const keys = new Map<string, AppliedCapEntry>()
|
|
421
|
+
for (const addon of manifest) {
|
|
422
|
+
const registryKey = registryKeyFor(addon.addonId)
|
|
423
|
+
for (const capName of addon.capabilities) {
|
|
424
|
+
if (isInfraCapability(capName)) continue
|
|
425
|
+
const capDef = registry.getDefinition(capName)
|
|
426
|
+
if (!capDef) continue
|
|
427
|
+
keys.set(`${registryKey}::${capName}`, { addonId: addon.addonId, capName, capDef })
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return keys
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const desired = appliedKeys(addons)
|
|
434
|
+
const previous = appliedKeys(previousManifest ?? [])
|
|
435
|
+
|
|
436
|
+
// ── UNREGISTER ── caps the previous manifest applied but the new one
|
|
437
|
+
// does not. Quiet — readiness is driven by `$node.connected/disconnected`,
|
|
438
|
+
// not by a re-handshake, so no readiness events here.
|
|
439
|
+
for (const [key, { addonId, capName }] of previous) {
|
|
440
|
+
if (desired.has(key)) continue
|
|
441
|
+
registry.unregisterProvider(capName, registryKeyFor(addonId))
|
|
442
|
+
this.nodeCallFns.delete(`${nodeId}::${capName}`)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── REGISTER ── caps the new manifest applies that the previous one
|
|
446
|
+
// did not. Caps present in BOTH sets are left untouched — zero churn,
|
|
447
|
+
// no duplicate `registerProvider`, no spurious page/widget re-emit.
|
|
448
|
+
for (const [key, { addonId, capName, capDef }] of desired) {
|
|
449
|
+
if (previous.has(key)) continue
|
|
450
|
+
|
|
451
|
+
const registryKey = registryKeyFor(addonId)
|
|
452
|
+
|
|
453
|
+
// Build a callFn that routes through Moleculer, matching the shape
|
|
454
|
+
// produced by CapabilityBridge: service name = addonId, action path
|
|
455
|
+
// = `<addonId>.<capName>.<method>`, nodeID = the registering node.
|
|
456
|
+
const callFn: CallFn =
|
|
457
|
+
(method: string, methodParams: unknown, targetNodeId?: string) =>
|
|
458
|
+
callWithServiceDiscovery(
|
|
459
|
+
this.brokerSafe,
|
|
460
|
+
addonId,
|
|
461
|
+
`${addonId}.${capName}.${method}`,
|
|
462
|
+
serializeTypedArrays(methodParams),
|
|
463
|
+
{ nodeID: targetNodeId ?? nodeId, timeout: 60_000 },
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
const proxy: Record<string, unknown> = { id: addonId, nodeId }
|
|
467
|
+
for (const methodName of Object.keys(expandCapMethods(capDef))) {
|
|
468
|
+
proxy[methodName] = (methodParams: unknown) => callFn(methodName, methodParams)
|
|
469
|
+
}
|
|
470
|
+
registry.registerProvider(capName, registryKey, proxy)
|
|
471
|
+
|
|
472
|
+
// Emit AddonPageReady / AddonWidgetReady so the admin-UI sidebar
|
|
473
|
+
// refreshes its page/widget registry for cross-process addons.
|
|
474
|
+
if (capName === 'addon-pages-source') {
|
|
475
|
+
this.eventBus.emit({
|
|
476
|
+
id: randomUUID(),
|
|
477
|
+
timestamp: new Date(),
|
|
478
|
+
source: { type: 'addon', id: addonId },
|
|
479
|
+
category: EventCategory.AddonPageReady,
|
|
480
|
+
data: { addonId, nodeId },
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
if (capName === 'addon-widgets-source') {
|
|
484
|
+
this.eventBus.emit({
|
|
485
|
+
id: randomUUID(),
|
|
486
|
+
timestamp: new Date(),
|
|
487
|
+
source: { type: 'addon', id: addonId },
|
|
488
|
+
category: EventCategory.AddonWidgetReady,
|
|
489
|
+
data: { addonId, nodeId },
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Store callFn so `callCapabilityOnNode` and `createCapabilityProxy`
|
|
494
|
+
// can reach manifest-registered nodes.
|
|
495
|
+
this.nodeCallFns.set(`${nodeId}::${capName}`, callFn)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* D3: remove a node's manifest from the CapabilityRegistry on disconnect.
|
|
501
|
+
* Unregisters every cap the node's last manifest declared and emits
|
|
502
|
+
* synthetic readiness-down events for each.
|
|
503
|
+
*/
|
|
504
|
+
private removeNodeFromRegistry(nodeId: string): void {
|
|
505
|
+
const manifest = this.nodeRegistry.getNodeManifest(nodeId)
|
|
506
|
+
if (!manifest) return // node never sent a handshake — nothing to do
|
|
507
|
+
|
|
508
|
+
const hubNodeId = this.brokerSafe.nodeID
|
|
509
|
+
const isLocalChild = nodeId.startsWith(hubNodeId + '/')
|
|
510
|
+
const disconnectGen = `disconnect-${nodeId}-${randomUUID()}`
|
|
511
|
+
const agentNodeId = nodeId.includes('/') ? nodeId.split('/')[0]! : nodeId
|
|
512
|
+
|
|
513
|
+
const registry = this.capabilityService.getRegistry()
|
|
514
|
+
|
|
515
|
+
for (const addon of manifest) {
|
|
516
|
+
const { addonId, capabilities } = addon
|
|
517
|
+
const registryKey = isLocalChild ? addonId : `${addonId}@${nodeId}`
|
|
518
|
+
|
|
519
|
+
if (registry) {
|
|
520
|
+
for (const capName of capabilities) {
|
|
521
|
+
registry.unregisterProvider(capName, registryKey)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
for (const capName of capabilities) {
|
|
526
|
+
this.nodeCallFns.delete(`${nodeId}::${capName}`)
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
emitReadiness(this.eventBus, {
|
|
530
|
+
capName,
|
|
531
|
+
scope: { type: 'node', nodeId: agentNodeId },
|
|
532
|
+
state: 'down',
|
|
533
|
+
generation: disconnectGen,
|
|
534
|
+
sourceNodeId: hubNodeId,
|
|
535
|
+
})
|
|
536
|
+
} catch (err) {
|
|
537
|
+
this.logger.warn('Failed to emit synthetic readiness down', {
|
|
538
|
+
tags: { addonId, nodeId },
|
|
539
|
+
meta: { capName, err: err instanceof Error ? err.message : String(err) },
|
|
540
|
+
})
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
this.nodeRegistry.removeNode(nodeId)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private findCallFn(nodeId: string, capabilityName: string): CallFn | undefined {
|
|
549
|
+
// Exact match first — direct hit when the caller knows the full
|
|
550
|
+
// nodeId (e.g. `hub/detection-pipeline`) or when the cap is hosted
|
|
551
|
+
// on a bare top-level node (remote agent with in-process addons).
|
|
552
|
+
const direct = this.nodeCallFns.get(`${nodeId}::${capabilityName}`)
|
|
553
|
+
if (direct) return direct
|
|
554
|
+
// Prefix fallback: forkable addons register under
|
|
555
|
+
// `<parent>/<processName>` (e.g. `dev-agent-0/detection-pipeline`).
|
|
556
|
+
// UI callers typically pass the bare parent nodeId (`dev-agent-0`)
|
|
557
|
+
// because that's what they get from AgentOnline events and the
|
|
558
|
+
// orchestrator assignments. Resolve by finding any registered node
|
|
559
|
+
// whose id starts with `<nodeId>/` and hosts the cap.
|
|
560
|
+
const prefix = `${nodeId}/`
|
|
561
|
+
for (const key of this.nodeCallFns.keys()) {
|
|
562
|
+
const sep = key.lastIndexOf('::')
|
|
563
|
+
if (sep < 0) continue
|
|
564
|
+
const keyNode = key.slice(0, sep)
|
|
565
|
+
const keyCap = key.slice(sep + 2)
|
|
566
|
+
if (keyCap !== capabilityName) continue
|
|
567
|
+
if (keyNode.startsWith(prefix)) return this.nodeCallFns.get(key)
|
|
568
|
+
}
|
|
569
|
+
return undefined
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Build a proxy object that forwards every method call on a capability
|
|
575
|
+
* to a remote node via Moleculer. Returns null if the capability is not
|
|
576
|
+
* reachable on that node. Used by the generated cap routers when a
|
|
577
|
+
* request includes a `nodeId` field for transparent node routing.
|
|
578
|
+
*/
|
|
579
|
+
createCapabilityProxy(capabilityName: string, nodeId: string): Record<string, (params: unknown) => Promise<unknown>> | null {
|
|
580
|
+
// Check if we can reach this capability on the target node
|
|
581
|
+
const callFn = this.findCallFn(nodeId, capabilityName)
|
|
582
|
+
if (!callFn) return null
|
|
583
|
+
|
|
584
|
+
// Build a dynamic proxy: every property access returns a function that
|
|
585
|
+
// calls the remote capability method via the stored callFn.
|
|
586
|
+
return new Proxy<Record<string, (params: unknown) => Promise<unknown>>>({}, {
|
|
587
|
+
get: (_target, methodName: string) => {
|
|
588
|
+
return (params: unknown): Promise<unknown> =>
|
|
589
|
+
this.callCapabilityOnNode(nodeId, capabilityName, methodName, params)
|
|
590
|
+
},
|
|
591
|
+
})
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* D3 handshake-fed native-cap view of the whole cluster.
|
|
596
|
+
* Returns every `(nodeId, addonId, capName, deviceId)` tuple stored by
|
|
597
|
+
* `onRegisterNode` — updated atomically each time a node re-handshakes
|
|
598
|
+
* (e.g. after device restore completes). Used by `device-manager.addon.ts`
|
|
599
|
+
* as a reliable fallback when push-based `DeviceBindingsChanged` events
|
|
600
|
+
* were lost in the Moleculer transport handshake window.
|
|
601
|
+
*
|
|
602
|
+
* NOT a Moleculer action — only the hub process calls this directly
|
|
603
|
+
* through the `ctx.kernel.listClusterNativeCaps` injection.
|
|
604
|
+
*/
|
|
605
|
+
listClusterNativeCaps(): readonly import('@camstack/kernel').NodeNativeCapEntry[] {
|
|
606
|
+
return this.nodeRegistry.listNativeCapEntries()
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async onModuleDestroy(): Promise<void> {
|
|
610
|
+
await this.brokerSafe.stop()
|
|
611
|
+
}
|
|
612
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { NetworkQualityService } from './network-quality.service'
|
|
3
|
+
|
|
4
|
+
describe('NetworkQualityService', () => {
|
|
5
|
+
let service: NetworkQualityService
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
service = new NetworkQualityService()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('should return null for unknown device', () => {
|
|
12
|
+
expect(service.getDeviceStats(999)).toBeNull()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('should track stream bitrate with rolling average', () => {
|
|
16
|
+
service.reportStreamStats(1, 'main', 8000)
|
|
17
|
+
service.reportStreamStats(1, 'main', 10000)
|
|
18
|
+
service.reportStreamStats(1, 'main', 9000)
|
|
19
|
+
|
|
20
|
+
const stats = service.getDeviceStats(1)
|
|
21
|
+
expect(stats).not.toBeNull()
|
|
22
|
+
expect(stats!.streams['main']!.observedBitrateKbps).toBe(9000)
|
|
23
|
+
expect(stats!.streams['main']!.peakBitrateKbps).toBe(10000)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should track packet loss', () => {
|
|
27
|
+
service.reportStreamStats(1, 'main', 8000, 0.5)
|
|
28
|
+
service.reportStreamStats(1, 'main', 8000, 1.5)
|
|
29
|
+
|
|
30
|
+
const stats = service.getDeviceStats(1)
|
|
31
|
+
expect(stats!.streams['main']!.packetLossPercent).toBe(1.0)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should track client stats', () => {
|
|
35
|
+
service.reportClientStats(1, { rttMs: 50, jitterMs: 5, estimatedBandwidthKbps: 20000 })
|
|
36
|
+
const stats = service.getDeviceStats(1)
|
|
37
|
+
expect(stats!.client?.rttMs).toBe(50)
|
|
38
|
+
expect(stats!.client?.estimatedBandwidthKbps).toBe(20000)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should list all device stats', () => {
|
|
42
|
+
service.reportStreamStats(1, 'main', 8000)
|
|
43
|
+
service.reportStreamStats(2, 'sub', 2000)
|
|
44
|
+
const all = service.getAllStats()
|
|
45
|
+
expect(all).toHaveLength(2)
|
|
46
|
+
})
|
|
47
|
+
})
|