@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.
Files changed (133) hide show
  1. package/.env.example +17 -0
  2. package/package.json +55 -0
  3. package/src/__tests__/addon-install-e2e.test.ts +75 -0
  4. package/src/__tests__/addon-pages-e2e.test.ts +178 -0
  5. package/src/__tests__/addon-route-session.test.ts +17 -0
  6. package/src/__tests__/addon-settings-router.spec.ts +62 -0
  7. package/src/__tests__/addon-upload.spec.ts +355 -0
  8. package/src/__tests__/agent-registry.spec.ts +162 -0
  9. package/src/__tests__/agent-status-page.spec.ts +84 -0
  10. package/src/__tests__/auth-session-cookie.test.ts +21 -0
  11. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
  12. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
  13. package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
  14. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
  15. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
  16. package/src/__tests__/cap-routers/harness.ts +159 -0
  17. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
  18. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
  19. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
  20. package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
  21. package/src/__tests__/capability-e2e.test.ts +386 -0
  22. package/src/__tests__/cli-e2e.test.ts +129 -0
  23. package/src/__tests__/core-cap-bridge.spec.ts +89 -0
  24. package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
  25. package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
  26. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
  27. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
  28. package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
  29. package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
  30. package/src/__tests__/framework-allowlist.spec.ts +95 -0
  31. package/src/__tests__/https-e2e.test.ts +118 -0
  32. package/src/__tests__/lifecycle-e2e.test.ts +140 -0
  33. package/src/__tests__/live-events-subscription.spec.ts +150 -0
  34. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
  35. package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
  36. package/src/__tests__/post-boot-restart.spec.ts +161 -0
  37. package/src/__tests__/singleton-contention.test.ts +487 -0
  38. package/src/__tests__/streaming-diagnostic.test.ts +512 -0
  39. package/src/__tests__/streaming-scale.test.ts +280 -0
  40. package/src/agent-status-page.ts +121 -0
  41. package/src/api/__tests__/addons-custom.spec.ts +134 -0
  42. package/src/api/__tests__/capabilities.router.test.ts +47 -0
  43. package/src/api/addon-upload.ts +472 -0
  44. package/src/api/addons-custom.router.ts +100 -0
  45. package/src/api/auth-whoami.ts +99 -0
  46. package/src/api/bridge-addons.router.ts +120 -0
  47. package/src/api/capabilities.router.ts +226 -0
  48. package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
  49. package/src/api/core/addon-settings.router.ts +124 -0
  50. package/src/api/core/agents.router.ts +87 -0
  51. package/src/api/core/auth.router.ts +303 -0
  52. package/src/api/core/cap-providers.ts +993 -0
  53. package/src/api/core/capabilities.router.ts +119 -0
  54. package/src/api/core/collection-preference.ts +40 -0
  55. package/src/api/core/event-bus-proxy.router.ts +45 -0
  56. package/src/api/core/hwaccel.router.ts +81 -0
  57. package/src/api/core/live-events.router.ts +60 -0
  58. package/src/api/core/logs.router.ts +162 -0
  59. package/src/api/core/notifications.router.ts +65 -0
  60. package/src/api/core/repl.router.ts +41 -0
  61. package/src/api/core/settings-backend.router.ts +142 -0
  62. package/src/api/core/stream-probe.router.ts +57 -0
  63. package/src/api/core/system-events.router.ts +116 -0
  64. package/src/api/health/health.routes.ts +123 -0
  65. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
  66. package/src/api/oauth2/consent-page.ts +42 -0
  67. package/src/api/oauth2/oauth2-routes.ts +248 -0
  68. package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
  69. package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
  70. package/src/api/trpc/cap-mount-helpers.ts +225 -0
  71. package/src/api/trpc/core-cap-bridge.ts +152 -0
  72. package/src/api/trpc/generated-cap-mounts.ts +707 -0
  73. package/src/api/trpc/generated-cap-routers.ts +6340 -0
  74. package/src/api/trpc/scope-access.ts +110 -0
  75. package/src/api/trpc/trpc.context.ts +255 -0
  76. package/src/api/trpc/trpc.middleware.ts +140 -0
  77. package/src/api/trpc/trpc.router.ts +275 -0
  78. package/src/auth/session-cookie.ts +44 -0
  79. package/src/boot/boot-config.ts +278 -0
  80. package/src/boot/post-boot.service.ts +103 -0
  81. package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
  82. package/src/core/addon/addon-package.service.ts +1684 -0
  83. package/src/core/addon/addon-registry.service.ts +2926 -0
  84. package/src/core/addon/addon-search.service.ts +90 -0
  85. package/src/core/addon/addon-settings-provider.ts +276 -0
  86. package/src/core/addon/addon.tokens.ts +2 -0
  87. package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
  88. package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
  89. package/src/core/addon-pages/addon-pages.service.ts +80 -0
  90. package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
  91. package/src/core/agent/agent-registry.service.ts +507 -0
  92. package/src/core/auth/auth.service.spec.ts +88 -0
  93. package/src/core/auth/auth.service.ts +8 -0
  94. package/src/core/capability/capability.service.ts +57 -0
  95. package/src/core/config/config.schema.ts +3 -0
  96. package/src/core/config/config.service.spec.ts +175 -0
  97. package/src/core/config/config.service.ts +7 -0
  98. package/src/core/events/event-bus.service.spec.ts +212 -0
  99. package/src/core/events/event-bus.service.ts +85 -0
  100. package/src/core/feature/feature.service.spec.ts +96 -0
  101. package/src/core/feature/feature.service.ts +8 -0
  102. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
  103. package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
  104. package/src/core/logging/log-ring-buffer.ts +3 -0
  105. package/src/core/logging/logging.service.spec.ts +247 -0
  106. package/src/core/logging/logging.service.ts +129 -0
  107. package/src/core/logging/scoped-logger.ts +3 -0
  108. package/src/core/moleculer/moleculer.service.ts +612 -0
  109. package/src/core/network/network-quality.service.spec.ts +47 -0
  110. package/src/core/network/network-quality.service.ts +5 -0
  111. package/src/core/notification/notification-wrapper.service.ts +36 -0
  112. package/src/core/notification/toast-wrapper.service.ts +31 -0
  113. package/src/core/provider/provider.tokens.ts +1 -0
  114. package/src/core/repl/repl-engine.service.spec.ts +417 -0
  115. package/src/core/repl/repl-engine.service.ts +156 -0
  116. package/src/core/storage/fs-storage-backend.spec.ts +70 -0
  117. package/src/core/storage/fs-storage-backend.ts +3 -0
  118. package/src/core/storage/settings-store.spec.ts +213 -0
  119. package/src/core/storage/settings-store.ts +2 -0
  120. package/src/core/storage/sql-schema.spec.ts +140 -0
  121. package/src/core/storage/sql-schema.ts +3 -0
  122. package/src/core/storage/storage-location-manager.spec.ts +121 -0
  123. package/src/core/storage/storage-location-manager.ts +3 -0
  124. package/src/core/storage/storage.service.spec.ts +73 -0
  125. package/src/core/storage/storage.service.ts +3 -0
  126. package/src/core/streaming/stream-probe.service.ts +212 -0
  127. package/src/core/topology/topology-emitter.service.ts +101 -0
  128. package/src/launcher.ts +309 -0
  129. package/src/main.ts +1049 -0
  130. package/src/manual-boot.ts +322 -0
  131. package/tsconfig.build.json +8 -0
  132. package/tsconfig.json +21 -0
  133. package/vitest.config.ts +26 -0
@@ -0,0 +1,512 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-argument -- test file, mock typing */
2
+ /**
3
+ * Stream diagnostic — measures quality, latency, and fanout.
4
+ * Run: FRIGATE_HOST=192.168.1.128 npx vitest run server/backend/src/__tests__/streaming-diagnostic.test.ts --reporter verbose
5
+ */
6
+
7
+ // E2E diagnostic test with heavy broker usage — file-level disable for cascading unsafe rules
8
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */
9
+
10
+ import { describe, it, expect, afterAll } from 'vitest'
11
+ import { StreamBrokerManager, FfmpegDecoderProvider } from '@camstack/addon-pipeline/stream-broker'
12
+ import type { EncodedPacket, FrameHandle, IStreamBroker } from '@camstack/types'
13
+ import type { IScopedLogger } from '@camstack/types'
14
+
15
+ const FRIGATE = process.env.FRIGATE_HOST ?? ''
16
+ const STREAMS = [
17
+ { id: 'ingresso_main', url: `rtsp://${FRIGATE}:8554/ingresso_main`, codec: 'h264' },
18
+ { id: 'salone_main', url: `rtsp://${FRIGATE}:8554/salone_main`, codec: 'h264' },
19
+ ]
20
+
21
+ const mockLogger: IScopedLogger = {
22
+ debug: () => {},
23
+ info: () => {},
24
+ warn: console.warn,
25
+ error: console.error,
26
+ child: () => mockLogger,
27
+ }
28
+
29
+ const mockLoggingService = {
30
+ createLogger: () => mockLogger,
31
+ }
32
+
33
+ function wait(ms: number) {
34
+ return new Promise(r => setTimeout(r, ms))
35
+ }
36
+
37
+ function waitForFrames(count: number, frames: unknown[], timeoutMs: number): Promise<void> {
38
+ return new Promise((resolve, reject) => {
39
+ const start = Date.now()
40
+ const iv = setInterval(() => {
41
+ if (frames.length >= count) { clearInterval(iv); resolve() }
42
+ if (Date.now() - start > timeoutMs) { clearInterval(iv); reject(new Error(`Timeout: got ${frames.length}/${count} frames`)) }
43
+ }, 50)
44
+ })
45
+ }
46
+
47
+ /**
48
+ * Poll-based replacement for the removed `IStreamBroker.onDecodedFrame`
49
+ * callback (Phase 5 / D9 — decoded frames now travel as shm `FrameHandle`s).
50
+ * Opens a frame-handle subscription, drains `pullFrameHandles` on a 50ms
51
+ * timer, and invokes `onHandle` once per decoded frame. The returned
52
+ * function tears the subscription down. `byteLength` stands in for the old
53
+ * `DecodedFrame.data.length`; `pts` for `DecodedFrame.timestamp`.
54
+ */
55
+ async function subscribeDecodedFrames(
56
+ broker: IStreamBroker,
57
+ maxFps: number,
58
+ onHandle: (handle: FrameHandle) => void,
59
+ ): Promise<() => Promise<void>> {
60
+ const { subscriptionId } = await broker.subscribeFrameHandles({ format: 'rgb', maxFps })
61
+ const iv = setInterval(() => {
62
+ for (const handle of broker.pullFrameHandles(subscriptionId, 8)) {
63
+ onHandle(handle)
64
+ }
65
+ }, 50)
66
+ return async () => {
67
+ clearInterval(iv)
68
+ await broker.unsubscribeFrameHandles(subscriptionId)
69
+ }
70
+ }
71
+
72
+ describe.skipIf(!FRIGATE)('Stream Diagnostic (requires Frigate)', () => {
73
+ const manager = new StreamBrokerManager([new FfmpegDecoderProvider()], mockLogger)
74
+
75
+ afterAll(async () => {
76
+ await manager.destroyAll()
77
+ })
78
+
79
+ it('measures first-frame latency per stream', async () => {
80
+ console.log('\n--- First-Frame Latency ---')
81
+
82
+ for (const s of STREAMS) {
83
+ const broker = await manager.createBroker(`latency-${s.id}`, {
84
+ type: 'rtsp', url: s.url, videoCodec: s.codec,
85
+ })
86
+ const startMs = Date.now()
87
+ await (broker as any).start({ type: 'rtsp', url: s.url, videoCodec: s.codec })
88
+
89
+ const result = await new Promise<{ latencyMs: number; sizeKB: number }>((resolve, reject) => {
90
+ const timeout = setTimeout(() => reject(new Error('No frame in 15s')), 15000)
91
+ let unsub: (() => Promise<void>) | undefined
92
+ subscribeDecodedFrames(broker, 10, (handle: FrameHandle) => {
93
+ clearTimeout(timeout)
94
+ void unsub?.()
95
+ resolve({ latencyMs: Date.now() - startMs, sizeKB: +(handle.byteLength / 1024).toFixed(1) })
96
+ }).then((fn) => { unsub = fn }).catch(reject)
97
+ })
98
+
99
+ console.log(` ${s.id}: ${result.latencyMs}ms, frame=${result.sizeKB}KB`)
100
+ expect(result.latencyMs).toBeLessThan(15000)
101
+ expect(result.sizeKB).toBeGreaterThan(1) // at least 1KB JPEG
102
+
103
+ await broker.stop()
104
+ await manager.destroyBroker(`latency-${s.id}`)
105
+ }
106
+ }, 30000)
107
+
108
+ it('measures frame quality over 5 seconds', async () => {
109
+ console.log('\n--- Frame Quality (5s @ 5fps) ---')
110
+
111
+ const broker = await manager.createBroker('quality', {
112
+ type: 'rtsp', url: STREAMS[0]!.url, videoCodec: 'h264',
113
+ })
114
+ await (broker as any).start({ type: 'rtsp', url: STREAMS[0]!.url, videoCodec: 'h264' })
115
+
116
+ const frames: { sizeKB: number; ts: number }[] = []
117
+
118
+ // Wait for first frame before measuring
119
+ await new Promise<void>((resolve, reject) => {
120
+ const timeout = setTimeout(() => reject(new Error('No initial frame')), 10000)
121
+ let unsub: (() => Promise<void>) | undefined
122
+ subscribeDecodedFrames(broker, 5, () => {
123
+ clearTimeout(timeout)
124
+ void unsub?.()
125
+ resolve()
126
+ }).then((fn) => { unsub = fn }).catch(reject)
127
+ })
128
+
129
+ // Now collect for 5 seconds
130
+ const unsub = await subscribeDecodedFrames(broker, 5, (handle: FrameHandle) => {
131
+ frames.push({ sizeKB: +(handle.byteLength / 1024).toFixed(1), ts: handle.pts })
132
+ })
133
+
134
+ await wait(5000)
135
+ await unsub()
136
+
137
+ const sizes = frames.map(f => f.sizeKB)
138
+ const avgSize = +(sizes.reduce((a, b) => a + b, 0) / sizes.length).toFixed(1)
139
+ const minSize = Math.min(...sizes)
140
+ const maxSize = Math.max(...sizes)
141
+
142
+ // Frame-to-frame gaps
143
+ const gaps: number[] = []
144
+ for (let i = 1; i < frames.length; i++) {
145
+ gaps.push(frames[i]!.ts - frames[i - 1]!.ts)
146
+ }
147
+ const avgGap = gaps.length > 0 ? Math.round(gaps.reduce((a, b) => a + b, 0) / gaps.length) : 0
148
+ const maxGap = gaps.length > 0 ? Math.max(...gaps) : 0
149
+
150
+ console.log(` Frames: ${frames.length} (expected ~25 @ 5fps)`)
151
+ console.log(` JPEG size: avg=${avgSize}KB, min=${minSize}KB, max=${maxSize}KB`)
152
+ console.log(` Frame gap: avg=${avgGap}ms, max=${maxGap}ms (expected ~200ms @ 5fps)`)
153
+ console.log(` All valid: ${frames.every(f => f.sizeKB > 0.5) ? 'YES' : 'NO'}`)
154
+
155
+ expect(frames.length).toBeGreaterThan(10)
156
+ expect(avgSize).toBeGreaterThan(1) // > 1KB avg = real JPEG content
157
+ expect(avgGap).toBeLessThan(500) // not more than 500ms avg gap at 5fps
158
+
159
+ await broker.stop()
160
+ await manager.destroyBroker('quality')
161
+ }, 30000)
162
+
163
+ it('measures fanout: encoded + 2 decoded subscribers', async () => {
164
+ console.log('\n--- Fanout (3s, encoded + 2 decoded) ---')
165
+
166
+ const broker = await manager.createBroker('fanout', {
167
+ type: 'rtsp', url: STREAMS[0]!.url, videoCodec: 'h264',
168
+ })
169
+ await (broker as any).start({ type: 'rtsp', url: STREAMS[0]!.url, videoCodec: 'h264' })
170
+
171
+ let encodedCount = 0
172
+ let encodedBytes = 0
173
+ let keyframes = 0
174
+ let decodedA = 0
175
+ let decodedB = 0
176
+
177
+ // Wait for stream to start
178
+ await new Promise<void>((resolve, reject) => {
179
+ const timeout = setTimeout(() => reject(new Error('No frame')), 10000)
180
+ let unsub: (() => Promise<void>) | undefined
181
+ subscribeDecodedFrames(broker, 10, () => {
182
+ clearTimeout(timeout)
183
+ void unsub?.()
184
+ resolve()
185
+ }).then((fn) => { unsub = fn }).catch(reject)
186
+ })
187
+
188
+ // Subscribe all 3
189
+ const unsubE = broker.onEncodedData((pkt: EncodedPacket) => {
190
+ encodedCount++
191
+ encodedBytes += pkt.data.length
192
+ if (pkt.keyframe) keyframes++
193
+ })
194
+ // Phase 5 / D9: the broker no longer throttles per-subscriber fps — the
195
+ // shm frame plane keeps one decoder session per format (here both
196
+ // subscribers share the `rgb` ring) and latest-wins ring reads drop
197
+ // frames implicitly. Both subscribers therefore see the same cadence;
198
+ // the old per-subscriber fps-isolation guarantee is gone.
199
+ const unsubD1 = await subscribeDecodedFrames(broker, 5, () => { decodedA++ })
200
+ const unsubD2 = await subscribeDecodedFrames(broker, 5, () => { decodedB++ })
201
+
202
+ await wait(3000)
203
+
204
+ // Capture stats BEFORE unsubscribing
205
+ const stats = broker.getStats()
206
+
207
+ unsubE()
208
+ await unsubD1()
209
+ await unsubD2()
210
+
211
+ console.log(` Encoded packets: ${encodedCount} (${(encodedBytes / 1024).toFixed(0)}KB, ${keyframes} keyframes)`)
212
+ console.log(` Decoded subscriber A: ${decodedA} frames`)
213
+ console.log(` Decoded subscriber B: ${decodedB} frames`)
214
+ console.log(` Broker stats: inputFps=${stats.inputFps}, decodeFps=${stats.decodeFps}, uptime=${stats.uptimeMs}ms`)
215
+
216
+ // Assertions
217
+ expect(encodedCount).toBeGreaterThan(0)
218
+ expect(decodedA).toBeGreaterThan(0)
219
+ expect(decodedB).toBeGreaterThan(0)
220
+ expect(stats.decodeFps).toBeGreaterThan(0) // plane's aggregate decode rate
221
+ expect(keyframes).toBeGreaterThan(0) // at least 1 keyframe in 3s
222
+
223
+ await broker.stop()
224
+ await manager.destroyBroker('fanout')
225
+ }, 30000)
226
+
227
+ it('measures concurrent broker isolation (2 cameras)', async () => {
228
+ console.log('\n--- Concurrent Brokers (2 cameras, 3s @ 3fps) ---')
229
+
230
+ const results: Record<string, { frames: number; sizes: number[] }> = {}
231
+ const brokerIds: string[] = []
232
+ const collectUnsubs: Array<() => Promise<void>> = []
233
+
234
+ for (const s of STREAMS) {
235
+ const brokerId = `concurrent-${s.id}`
236
+ brokerIds.push(brokerId)
237
+ const broker = await manager.createBroker(brokerId, {
238
+ type: 'rtsp', url: s.url, videoCodec: s.codec,
239
+ })
240
+ await (broker as any).start({ type: 'rtsp', url: s.url, videoCodec: s.codec })
241
+ results[s.id] = { frames: 0, sizes: [] }
242
+
243
+ // Wait for first frame
244
+ await new Promise<void>((resolve, reject) => {
245
+ const timeout = setTimeout(() => reject(new Error(`No frame from ${s.id}`)), 10000)
246
+ let unsub: (() => Promise<void>) | undefined
247
+ subscribeDecodedFrames(broker, 5, () => {
248
+ clearTimeout(timeout)
249
+ void unsub?.()
250
+ resolve()
251
+ }).then((fn) => { unsub = fn }).catch(reject)
252
+ })
253
+
254
+ collectUnsubs.push(await subscribeDecodedFrames(broker, 3, (handle: FrameHandle) => {
255
+ results[s.id]!.frames++
256
+ results[s.id]!.sizes.push(handle.byteLength)
257
+ }))
258
+ }
259
+
260
+ await wait(3000)
261
+ for (const unsub of collectUnsubs) await unsub()
262
+
263
+ for (const s of STREAMS) {
264
+ const r = results[s.id]!
265
+ const avgKB = r.sizes.length > 0 ? +(r.sizes.reduce((a, b) => a + b, 0) / r.sizes.length / 1024).toFixed(1) : 0
266
+ console.log(` ${s.id}: ${r.frames} frames, avg=${avgKB}KB`)
267
+ }
268
+
269
+ const allProducing = Object.values(results).every(r => r.frames > 0)
270
+ console.log(` All producing: ${allProducing ? 'YES' : 'FAIL'}`)
271
+
272
+ expect(allProducing).toBe(true)
273
+
274
+ for (const id of brokerIds) {
275
+ const b = manager.getBroker(id)
276
+ if (b) { await b.stop() }
277
+ await manager.destroyBroker(id)
278
+ }
279
+ }, 30000)
280
+
281
+ it('stress test: 3 cameras x 3 subscribers each at different FPS', async () => {
282
+ console.log('\n--- Stress: 3 cameras x 3 subs (5s) ---')
283
+
284
+ const cameras = [
285
+ { id: 'ingresso_main', url: `rtsp://${FRIGATE}:8554/ingresso_main` },
286
+ { id: 'ingresso_sub', url: `rtsp://${FRIGATE}:8554/ingresso_sub` },
287
+ { id: 'salone_main', url: `rtsp://${FRIGATE}:8554/salone_main` },
288
+ ]
289
+
290
+ // Phase 5 / D9: the broker no longer throttles per-subscriber fps. This
291
+ // stays a load test — 3 concurrent frame-handle subscribers per camera —
292
+ // but the obsolete per-subscriber fps-isolation assertion is dropped.
293
+ const subscriberCount = 3
294
+ const results: Record<string, Record<number, { frames: number; sizes: number[] }>> = {}
295
+ const unsubs: Array<() => Promise<void>> = []
296
+ const brokerIds: string[] = []
297
+
298
+ // Start all 3 cameras
299
+ for (const cam of cameras) {
300
+ const brokerId = `stress-${cam.id}`
301
+ brokerIds.push(brokerId)
302
+ results[cam.id] = {}
303
+
304
+ const broker = await manager.createBroker(brokerId, {
305
+ type: 'rtsp', url: cam.url, videoCodec: 'h264',
306
+ })
307
+ await (broker as any).start({ type: 'rtsp', url: cam.url, videoCodec: 'h264' })
308
+
309
+ // Wait for first frame (shared decoder warmup)
310
+ await new Promise<void>((resolve, reject) => {
311
+ const timeout = setTimeout(() => reject(new Error(`No frame from ${cam.id} in 10s`)), 10000)
312
+ let unsub: (() => Promise<void>) | undefined
313
+ subscribeDecodedFrames(broker, 10, () => {
314
+ clearTimeout(timeout)
315
+ void unsub?.()
316
+ resolve()
317
+ }).then((fn) => { unsub = fn }).catch(reject)
318
+ })
319
+
320
+ // Subscribe N concurrent frame-handle readers
321
+ for (let sub = 0; sub < subscriberCount; sub++) {
322
+ results[cam.id]![sub] = { frames: 0, sizes: [] }
323
+ unsubs.push(await subscribeDecodedFrames(broker, 5, (handle: FrameHandle) => {
324
+ results[cam.id]![sub]!.frames++
325
+ results[cam.id]![sub]!.sizes.push(handle.byteLength)
326
+ }))
327
+ }
328
+ }
329
+
330
+ // Let it run for 5 seconds
331
+ await wait(5000)
332
+
333
+ // Unsubscribe all
334
+ for (const unsub of unsubs) await unsub()
335
+
336
+ // Print results
337
+ console.log(' Camera | sub0 | sub1 | sub2 | Avg KB')
338
+ console.log(' -------------------|-------|-------|-------|-------')
339
+ let allOk = true
340
+ for (const cam of cameras) {
341
+ const r = results[cam.id]!
342
+ const allSizes = [...r[0]!.sizes, ...r[1]!.sizes, ...r[2]!.sizes]
343
+ const avgKB = allSizes.length > 0 ? +(allSizes.reduce((a, b) => a + b, 0) / allSizes.length / 1024).toFixed(0) : 0
344
+ const line = ` ${cam.id.padEnd(19)}| ${String(r[0]!.frames).padStart(5)} | ${String(r[1]!.frames).padStart(5)} | ${String(r[2]!.frames).padStart(5)} | ${avgKB}`
345
+ console.log(line)
346
+
347
+ // Every concurrent subscriber must produce frames.
348
+ for (let sub = 0; sub < subscriberCount; sub++) {
349
+ if (r[sub]!.frames === 0) allOk = false
350
+ }
351
+ }
352
+
353
+ // Verify totals
354
+ const totalFrames = Object.values(results).reduce((sum, cam) =>
355
+ sum + Object.values(cam).reduce((s, sub) => s + sub.frames, 0), 0)
356
+ const totalSubs = cameras.length * subscriberCount // 9
357
+ console.log(`\n Total: ${totalFrames} frames across ${totalSubs} subscribers`)
358
+ console.log(` All subs producing: ${allOk ? 'YES' : 'FAIL'}`)
359
+
360
+ // Assertions — every concurrent subscriber on every camera gets frames.
361
+ for (const cam of cameras) {
362
+ for (let sub = 0; sub < subscriberCount; sub++) {
363
+ expect(results[cam.id]![sub]!.frames, `${cam.id} sub${sub} should produce frames`).toBeGreaterThan(0)
364
+ }
365
+ }
366
+
367
+ // Cleanup
368
+ for (const id of brokerIds) {
369
+ const b = manager.getBroker(id)
370
+ if (b) await b.stop()
371
+ await manager.destroyBroker(id)
372
+ }
373
+ }, 60000)
374
+
375
+ it('benchmarks resource usage per stream (CPU, memory, bandwidth)', async () => {
376
+ console.log('\n--- Resource Benchmark (3 cameras, 5s each) ---')
377
+
378
+ const cameras = [
379
+ { id: 'ingresso_main', url: `rtsp://${FRIGATE}:8554/ingresso_main`, label: 'ingresso main (HD)' },
380
+ { id: 'ingresso_sub', url: `rtsp://${FRIGATE}:8554/ingresso_sub`, label: 'ingresso sub (SD)' },
381
+ { id: 'salone_main', url: `rtsp://${FRIGATE}:8554/salone_main`, label: 'salone main (HD)' },
382
+ ]
383
+
384
+ console.log(' Stream | Enc BW | Dec FPS | Dec KB/f | CPU % | RSS MB | Heap MB')
385
+ console.log(' --------------------|-----------|---------|----------|--------|--------|--------')
386
+
387
+ for (const cam of cameras) {
388
+ const brokerId = `bench-${cam.id}`
389
+ const broker = await manager.createBroker(brokerId, {
390
+ type: 'rtsp', url: cam.url, videoCodec: 'h264',
391
+ })
392
+ await (broker as any).start({ type: 'rtsp', url: cam.url, videoCodec: 'h264' })
393
+
394
+ // Wait for first frame
395
+ await new Promise<void>((resolve, reject) => {
396
+ const timeout = setTimeout(() => reject(new Error(`No frame from ${cam.id}`)), 10000)
397
+ let unsub: (() => Promise<void>) | undefined
398
+ subscribeDecodedFrames(broker, 10, () => {
399
+ clearTimeout(timeout)
400
+ void unsub?.()
401
+ resolve()
402
+ }).then((fn) => { unsub = fn }).catch(reject)
403
+ })
404
+
405
+ // Measure baseline
406
+ const memBefore = process.memoryUsage()
407
+ const cpuBefore = process.cpuUsage()
408
+ let encodedBytes = 0
409
+ let encodedPackets = 0
410
+ const decodedSizes: number[] = []
411
+
412
+ const unsubE = broker.onEncodedData((pkt: EncodedPacket) => {
413
+ encodedBytes += pkt.data.length
414
+ encodedPackets++
415
+ })
416
+ const unsubD = await subscribeDecodedFrames(broker, 5, (handle: FrameHandle) => {
417
+ decodedSizes.push(handle.byteLength)
418
+ })
419
+
420
+ // Run for 5 seconds
421
+ await wait(5000)
422
+
423
+ const cpuAfter = process.cpuUsage(cpuBefore)
424
+ const memAfter = process.memoryUsage()
425
+
426
+ unsubE()
427
+ await unsubD()
428
+
429
+ // Calculate metrics
430
+ const durationS = 5
431
+ const encBwKBs = (encodedBytes / 1024 / durationS).toFixed(0)
432
+ const decFps = (decodedSizes.length / durationS).toFixed(1)
433
+ const avgDecKB = decodedSizes.length > 0
434
+ ? (decodedSizes.reduce((a, b) => a + b, 0) / decodedSizes.length / 1024).toFixed(0)
435
+ : '0'
436
+ // CPU: user + system microseconds → percentage of 5s wall time
437
+ const cpuTotalUs = cpuAfter.user + cpuAfter.system
438
+ const cpuPct = (cpuTotalUs / (durationS * 1_000_000) * 100).toFixed(1)
439
+ const rssDeltaMB = ((memAfter.rss - memBefore.rss) / 1024 / 1024).toFixed(1)
440
+ const heapMB = (memAfter.heapUsed / 1024 / 1024).toFixed(1)
441
+
442
+ const label = cam.label.padEnd(20)
443
+ console.log(` ${label}| ${encBwKBs.padStart(5)} KB/s | ${decFps.padStart(7)} | ${avgDecKB.padStart(6)} KB | ${cpuPct.padStart(5)}% | ${rssDeltaMB.padStart(6)} | ${heapMB.padStart(7)}`)
444
+
445
+ await broker.stop()
446
+ await manager.destroyBroker(brokerId)
447
+
448
+ // Small pause between cameras for GC
449
+ await wait(500)
450
+ }
451
+
452
+ // Now test all 3 simultaneously
453
+ console.log(' --------------------|-----------|---------|----------|--------|--------|--------')
454
+ console.log(' All 3 concurrent:')
455
+
456
+ const memBaseline = process.memoryUsage()
457
+ const cpuBaseline = process.cpuUsage()
458
+ const allBrokers: string[] = []
459
+ const allUnsubs: Array<() => void | Promise<void>> = []
460
+ let totalEncBytes = 0
461
+ let totalDecFrames = 0
462
+
463
+ for (const cam of cameras) {
464
+ const brokerId = `bench-all-${cam.id}`
465
+ allBrokers.push(brokerId)
466
+ const broker = await manager.createBroker(brokerId, {
467
+ type: 'rtsp', url: cam.url, videoCodec: 'h264',
468
+ })
469
+ await (broker as any).start({ type: 'rtsp', url: cam.url, videoCodec: 'h264' })
470
+
471
+ await new Promise<void>((resolve, reject) => {
472
+ const timeout = setTimeout(() => reject(new Error(`No frame from ${cam.id}`)), 10000)
473
+ let unsub: (() => Promise<void>) | undefined
474
+ subscribeDecodedFrames(broker, 10, () => {
475
+ clearTimeout(timeout)
476
+ void unsub?.()
477
+ resolve()
478
+ }).then((fn) => { unsub = fn }).catch(reject)
479
+ })
480
+
481
+ allUnsubs.push(broker.onEncodedData((pkt: EncodedPacket) => { totalEncBytes += pkt.data.length }))
482
+ allUnsubs.push(await subscribeDecodedFrames(broker, 5, () => { totalDecFrames++ }))
483
+ }
484
+
485
+ await wait(5000)
486
+
487
+ const cpuAll = process.cpuUsage(cpuBaseline)
488
+ const memAll = process.memoryUsage()
489
+
490
+ for (const unsub of allUnsubs) await unsub()
491
+
492
+ const allCpuPct = ((cpuAll.user + cpuAll.system) / (5 * 1_000_000) * 100).toFixed(1)
493
+ const allRssMB = ((memAll.rss - memBaseline.rss) / 1024 / 1024).toFixed(1)
494
+ const allHeapMB = (memAll.heapUsed / 1024 / 1024).toFixed(1)
495
+ const allEncKBs = (totalEncBytes / 1024 / 5).toFixed(0)
496
+
497
+ console.log(` Encoded BW: ${allEncKBs} KB/s total`)
498
+ console.log(` Decoded: ${totalDecFrames} frames total (${(totalDecFrames / 5).toFixed(1)} fps combined)`)
499
+ console.log(` CPU: ${allCpuPct}%`)
500
+ console.log(` RSS delta: ${allRssMB} MB`)
501
+ console.log(` Heap: ${allHeapMB} MB`)
502
+
503
+ for (const id of allBrokers) {
504
+ const b = manager.getBroker(id)
505
+ if (b) await b.stop()
506
+ await manager.destroyBroker(id)
507
+ }
508
+
509
+ // Basic sanity — we should be able to handle 3 streams
510
+ expect(totalDecFrames).toBeGreaterThan(10)
511
+ }, 90000)
512
+ })