@uniai-fe/uds-templates 0.6.7 → 0.6.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -1,6 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import { startWhepStream, type WhepStreamHandle } from "@uniai-fe/util-rtc";
4
+ import {
5
+ getCctvDebugKeyLabel,
6
+ getCctvDebugUrlLabel,
7
+ logCctvDebugEvent,
8
+ } from "../utils/debug";
4
9
 
5
10
  interface CctvRtcStreamSnapshot {
6
11
  connectionState: RTCPeerConnectionState;
@@ -47,6 +52,18 @@ const notifyEntry = (entry: CctvRtcStreamEntry) => {
47
52
  };
48
53
 
49
54
  const closeEntry = (entry: CctvRtcStreamEntry) => {
55
+ logCctvDebugEvent({
56
+ event: "entry:close",
57
+ payload: {
58
+ attachedVideoCount: entry.attachedVideos.size,
59
+ connectionState: entry.connectionState,
60
+ identityKey: getCctvDebugKeyLabel(entry.identityKey),
61
+ isStreaming: entry.isStreaming,
62
+ streamKey: getCctvDebugKeyLabel(entry.streamKey),
63
+ streamTrackCount: entry.stream?.getTracks().length ?? 0,
64
+ },
65
+ source: "streamRegistry",
66
+ });
50
67
  entry.controller?.abort();
51
68
  entry.controller = null;
52
69
  entry.handle?.close();
@@ -110,6 +127,15 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
110
127
  entries.clear();
111
128
  },
112
129
  closeByIdentity(identityKey, exceptStreamKey) {
130
+ logCctvDebugEvent({
131
+ event: "identity:close",
132
+ payload: {
133
+ entryCount: entries.size,
134
+ exceptStreamKey: getCctvDebugKeyLabel(exceptStreamKey),
135
+ identityKey: getCctvDebugKeyLabel(identityKey),
136
+ },
137
+ source: "streamRegistry",
138
+ });
113
139
  entries.forEach(entry => {
114
140
  if (
115
141
  entry.identityKey === identityKey &&
@@ -124,9 +150,22 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
124
150
  },
125
151
  start({ endpoint, identityKey, streamKey, token, video }) {
126
152
  const existingEntry = entries.get(streamKey);
127
- if (existingEntry) return;
153
+ if (existingEntry) {
154
+ logCctvDebugEvent({
155
+ event: "start:reuse-existing",
156
+ payload: {
157
+ connectionState: existingEntry.connectionState,
158
+ identityKey: getCctvDebugKeyLabel(identityKey),
159
+ isStreaming: existingEntry.isStreaming,
160
+ streamKey: getCctvDebugKeyLabel(streamKey),
161
+ },
162
+ source: "streamRegistry",
163
+ });
164
+ return;
165
+ }
128
166
 
129
167
  const controller = new AbortController();
168
+ const startedAt = Date.now();
130
169
  const entry: CctvRtcStreamEntry = {
131
170
  attachedVideos: new Set([video]),
132
171
  connectionState: "connecting",
@@ -142,6 +181,16 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
142
181
 
143
182
  entries.set(streamKey, entry);
144
183
  attachVideo(video, null);
184
+ logCctvDebugEvent({
185
+ event: "start:create-entry",
186
+ payload: {
187
+ attachedVideoCount: entry.attachedVideos.size,
188
+ endpoint: getCctvDebugUrlLabel(endpoint),
189
+ identityKey: getCctvDebugKeyLabel(identityKey),
190
+ streamKey: getCctvDebugKeyLabel(streamKey),
191
+ },
192
+ source: "streamRegistry",
193
+ });
145
194
 
146
195
  startWhepStream({
147
196
  endpoint,
@@ -150,10 +199,33 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
150
199
  signal: controller.signal,
151
200
  onConnectionStateChange: state => {
152
201
  entry.connectionState = state;
202
+ logCctvDebugEvent({
203
+ event: "connection-state:change",
204
+ payload: {
205
+ elapsedMs: Date.now() - startedAt,
206
+ identityKey: getCctvDebugKeyLabel(identityKey),
207
+ state,
208
+ streamKey: getCctvDebugKeyLabel(streamKey),
209
+ },
210
+ source: "streamRegistry",
211
+ });
153
212
  notifyEntry(entry);
154
213
  },
155
214
  onTrack: event => {
156
215
  entry.stream = event.streams[0] ?? null;
216
+ logCctvDebugEvent({
217
+ event: "track:received",
218
+ payload: {
219
+ elapsedMs: Date.now() - startedAt,
220
+ identityKey: getCctvDebugKeyLabel(identityKey),
221
+ streamId: entry.stream?.id ?? null,
222
+ streamKey: getCctvDebugKeyLabel(streamKey),
223
+ trackId: event.track.id,
224
+ trackKind: event.track.kind,
225
+ trackReadyState: event.track.readyState,
226
+ },
227
+ source: "streamRegistry",
228
+ });
157
229
  entry.attachedVideos.forEach(attachedVideo =>
158
230
  attachVideo(attachedVideo, entry.stream),
159
231
  );
@@ -163,6 +235,16 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
163
235
  .then(handle => {
164
236
  entry.handle = handle;
165
237
  entry.isStreaming = false;
238
+ logCctvDebugEvent({
239
+ event: "start:resolved",
240
+ level: "info",
241
+ payload: {
242
+ elapsedMs: Date.now() - startedAt,
243
+ identityKey: getCctvDebugKeyLabel(identityKey),
244
+ streamKey: getCctvDebugKeyLabel(streamKey),
245
+ },
246
+ source: "streamRegistry",
247
+ });
166
248
  notifyEntry(entry);
167
249
  })
168
250
  .catch(error => {
@@ -174,6 +256,17 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
174
256
  error instanceof Error
175
257
  ? error.message
176
258
  : "스트림 연결에 실패했습니다.";
259
+ logCctvDebugEvent({
260
+ event: "start:rejected",
261
+ level: "error",
262
+ payload: {
263
+ elapsedMs: Date.now() - startedAt,
264
+ errorMessage: entry.streamError,
265
+ identityKey: getCctvDebugKeyLabel(identityKey),
266
+ streamKey: getCctvDebugKeyLabel(streamKey),
267
+ },
268
+ source: "streamRegistry",
269
+ });
177
270
  entry.attachedVideos.forEach(attachedVideo =>
178
271
  attachVideo(attachedVideo, null),
179
272
  );
@@ -19,27 +19,73 @@ import type {
19
19
  UseCctvRtcStreamReturn,
20
20
  } from "../types";
21
21
  import { cctvRtcLiveRegistryAtom } from "../jotai/rtc";
22
+ import {
23
+ getCctvDebugKeyLabel,
24
+ getCctvDebugStackTrace,
25
+ logCctvDebugEvent,
26
+ } from "../utils/debug";
22
27
  import { getIsLive } from "../utils/video-state";
23
28
  import { useFormContext, useWatch } from "react-hook-form";
24
29
 
25
30
  const AUTO_RECONNECT_INTERVAL_MS = 3000;
26
- const DISCONNECTED_RECONNECT_GRACE_MS = 5000;
27
- const DISCONNECTED_RECONNECT_STAGGER_MS = 5000;
31
+ const POST_CONNECTED_RECONNECT_GRACE_MS = 5000;
32
+ const POST_CONNECTED_RECONNECT_STAGGER_MS = 5000;
28
33
  const TOKEN_EXPIRY_SAFETY_MS = 30 * 1000;
29
34
 
35
+ type CctvRtcReconnectReason =
36
+ | "tokenError"
37
+ | "streamError"
38
+ | "failed"
39
+ | "disconnected";
40
+
41
+ const DISPLAY_CONNECTED_DURING_GRACE_STATES = new Set<RTCPeerConnectionState>([
42
+ "disconnected",
43
+ "failed",
44
+ ]);
45
+
46
+ /**
47
+ * 같은 회사/카메라 묶음이 동시에 재연결하지 않도록 identity 기반 지연값을 만든다.
48
+ * @desc
49
+ * 이 값은 보안용 난수가 아니라 UI/네트워크 부하를 분산하기 위한 안정 해시다.
50
+ */
30
51
  const getStableStaggerMs = (value: string): number => {
31
52
  let hash = 0;
32
53
 
33
54
  for (let i = 0; i < value.length; i += 1) {
34
55
  hash =
35
- (hash * 31 + value.charCodeAt(i)) % DISCONNECTED_RECONNECT_STAGGER_MS;
56
+ (hash * 31 + value.charCodeAt(i)) % POST_CONNECTED_RECONNECT_STAGGER_MS;
36
57
  }
37
58
 
38
59
  return hash;
39
60
  };
40
61
 
41
- const getDisconnectedReconnectDelayMs = (identityKey: string): number =>
42
- DISCONNECTED_RECONNECT_GRACE_MS + getStableStaggerMs(identityKey);
62
+ /**
63
+ * 최초 연결 이후 끊김/실패가 감지됐을 때 자동 재연결 노출 전 대기시간을 계산한다.
64
+ */
65
+ const getPostConnectedReconnectDelayMs = (delayKey: string): number =>
66
+ POST_CONNECTED_RECONNECT_GRACE_MS + getStableStaggerMs(delayKey);
67
+
68
+ /**
69
+ * 재연결이 필요한 장애 사유를 UDS 내부 기준으로 정규화한다.
70
+ * @desc
71
+ * `closed`는 `streamRegistry.closeByIdentity()`처럼 UDS가 의도적으로 닫을 때도 발생하므로
72
+ * 자동 재연결 사유에 포함하지 않는다.
73
+ */
74
+ const getPostConnectedReconnectReason = ({
75
+ connectionState,
76
+ isTokenError,
77
+ streamError,
78
+ }: {
79
+ connectionState: RTCPeerConnectionState;
80
+ isTokenError: boolean;
81
+ streamError: string | null;
82
+ }): CctvRtcReconnectReason | null => {
83
+ if (isTokenError) return "tokenError";
84
+ if (streamError) return "streamError";
85
+ if (connectionState === "failed") return "failed";
86
+ if (connectionState === "disconnected") return "disconnected";
87
+ return null;
88
+ };
43
89
 
44
90
  const decodeBase64Url = (value: string): string | null => {
45
91
  try {
@@ -102,7 +148,7 @@ const canUseTokenForNewConnection = ({
102
148
  * isStreaming, // 현재 스트림 연결 절차가 진행 중인지 여부
103
149
  * isTokenLoading, // 토큰 발급 요청이 진행 중인지 여부
104
150
  * isTokenError, // 토큰 발급 요청이 실패했는지 여부
105
- * canReconnect, // 재연결 가능한 종료 상태 여부
151
+ * canReconnect, // UDS 내부 grace/stagger 이후 재연결 호출이 가능한지 여부
106
152
  * refetchToken, // 토큰 발급을 재시도하는 함수
107
153
  * reconnectStream, // 재연결 trigger 함수
108
154
  * }
@@ -137,7 +183,7 @@ export function useCctvRtcStream({
137
183
  // 현재 스트림 연결 절차가 진행 중인지 여부.
138
184
  const [isStreaming, setStreaming] = useState(false);
139
185
  const [hasConnected, setHasConnected] = useState(false);
140
- const [isDisconnectedReconnectReady, setDisconnectedReconnectReady] =
186
+ const [isPostConnectedReconnectReady, setPostConnectedReconnectReady] =
141
187
  useState(false);
142
188
 
143
189
  // react-hook-form 컨텍스트에서 token username과 WHEP username override를 추적한다.
@@ -195,37 +241,106 @@ export function useCctvRtcStream({
195
241
  return [tokenUsername, cam.company_id, cam.cam_id, endpoint].join("|");
196
242
  }, [cam?.cam_id, cam?.company_id, endpoint, tokenUsername]);
197
243
 
244
+ const debugBasePayload = useMemo(
245
+ () => ({
246
+ camId: cam?.cam_id,
247
+ companyId: cam?.company_id,
248
+ streamIdentityKey: getCctvDebugKeyLabel(streamIdentityKey),
249
+ }),
250
+ [cam?.cam_id, cam?.company_id, streamIdentityKey],
251
+ );
252
+
198
253
  useEffect(() => {
254
+ logCctvDebugEvent({
255
+ event: "identity:reset-has-connected",
256
+ payload: debugBasePayload,
257
+ source: "useRtcStream",
258
+ });
199
259
  setHasConnected(false);
200
- }, [streamIdentityKey]);
260
+ }, [debugBasePayload, streamIdentityKey]);
261
+
262
+ const reconnectReason = useMemo(
263
+ () =>
264
+ getPostConnectedReconnectReason({
265
+ connectionState,
266
+ isTokenError,
267
+ streamError,
268
+ }),
269
+ [connectionState, isTokenError, streamError],
270
+ );
201
271
 
202
272
  useEffect(() => {
203
- if (
204
- connectionState !== "disconnected" ||
205
- !hasConnected ||
206
- !streamIdentityKey
207
- ) {
208
- setDisconnectedReconnectReady(false);
273
+ const canStartReconnectTimer =
274
+ hasConnected &&
275
+ Boolean(streamIdentityKey) &&
276
+ Boolean(reconnectReason) &&
277
+ !isTokenLoading &&
278
+ !isStreaming;
279
+
280
+ if (!canStartReconnectTimer) {
281
+ setPostConnectedReconnectReady(false);
209
282
  return;
210
283
  }
211
284
 
212
- setDisconnectedReconnectReady(false);
285
+ setPostConnectedReconnectReady(false);
286
+ const reconnectDelayMs = getPostConnectedReconnectDelayMs(
287
+ `${streamIdentityKey}|${reconnectReason}`,
288
+ );
289
+
290
+ logCctvDebugEvent({
291
+ event: "reconnect-gate:timer-start",
292
+ payload: {
293
+ ...debugBasePayload,
294
+ isStreaming,
295
+ isTokenLoading,
296
+ reconnectDelayMs,
297
+ reconnectReason,
298
+ },
299
+ source: "useRtcStream",
300
+ });
213
301
 
214
302
  const timeout = setTimeout(() => {
215
- setDisconnectedReconnectReady(true);
216
- }, getDisconnectedReconnectDelayMs(streamIdentityKey));
303
+ setPostConnectedReconnectReady(true);
304
+ logCctvDebugEvent({
305
+ event: "reconnect-gate:ready",
306
+ payload: {
307
+ ...debugBasePayload,
308
+ reconnectDelayMs,
309
+ reconnectReason,
310
+ },
311
+ source: "useRtcStream",
312
+ });
313
+ }, reconnectDelayMs);
217
314
 
218
315
  return () => {
219
316
  clearTimeout(timeout);
317
+ logCctvDebugEvent({
318
+ event: "reconnect-gate:timer-cancel",
319
+ payload: {
320
+ ...debugBasePayload,
321
+ reconnectDelayMs,
322
+ reconnectReason,
323
+ },
324
+ source: "useRtcStream",
325
+ });
220
326
  };
221
- }, [connectionState, hasConnected, streamIdentityKey]);
327
+ }, [
328
+ debugBasePayload,
329
+ hasConnected,
330
+ isStreaming,
331
+ isTokenLoading,
332
+ reconnectReason,
333
+ streamIdentityKey,
334
+ ]);
222
335
 
223
- const displayConnectionState =
224
- connectionState === "disconnected" &&
336
+ const shouldPreserveConnectedDisplay =
225
337
  hasConnected &&
226
- !isDisconnectedReconnectReady
227
- ? "connected"
228
- : connectionState;
338
+ !isPostConnectedReconnectReady &&
339
+ DISPLAY_CONNECTED_DURING_GRACE_STATES.has(connectionState);
340
+
341
+ const displayConnectionState = shouldPreserveConnectedDisplay
342
+ ? "connected"
343
+ : connectionState;
229
344
 
230
345
  const hasReusableRegistryStream = useMemo(() => {
231
346
  if (!streamKeyCandidate) return false;
@@ -276,12 +391,23 @@ export function useCctvRtcStream({
276
391
  }
277
392
 
278
393
  staleTokenRefreshKeyRef.current = streamKeyCandidate;
394
+ logCctvDebugEvent({
395
+ event: "token:stale-refetch",
396
+ payload: {
397
+ ...debugBasePayload,
398
+ dataUpdatedAt: tokenQuery.dataUpdatedAt,
399
+ streamKey: getCctvDebugKeyLabel(streamKeyCandidate),
400
+ },
401
+ source: "useRtcStream",
402
+ });
279
403
  void refetchRtcToken();
280
404
  }, [
281
405
  canUseTokenForStream,
406
+ debugBasePayload,
282
407
  refetchRtcToken,
283
408
  streamKeyCandidate,
284
409
  tokenQuery.data?.token,
410
+ tokenQuery.dataUpdatedAt,
285
411
  tokenQuery.isError,
286
412
  tokenQuery.isFetching,
287
413
  ]);
@@ -292,6 +418,15 @@ export function useCctvRtcStream({
292
418
 
293
419
  // 토큰/카메라 에러로 전환되면 직전 MediaStream이 화면에 남지 않도록 비운다.
294
420
  if (tokenQuery.isError || !cam?.cam_online) {
421
+ logCctvDebugEvent({
422
+ event: "stream-effect:cleanup-token-or-offline",
423
+ payload: {
424
+ ...debugBasePayload,
425
+ camOnline: cam?.cam_online,
426
+ isTokenError: tokenQuery.isError,
427
+ },
428
+ source: "useRtcStream",
429
+ });
295
430
  if (streamIdentityKey) {
296
431
  streamRegistry.closeByIdentity(streamIdentityKey);
297
432
  }
@@ -310,12 +445,30 @@ export function useCctvRtcStream({
310
445
  activeStreamIdentityKeyRef.current === streamIdentityKey &&
311
446
  activeStreamKeyRef.current !== streamKey
312
447
  ) {
448
+ logCctvDebugEvent({
449
+ event: "stream-effect:close-previous-identity",
450
+ payload: {
451
+ ...debugBasePayload,
452
+ activeStreamKey: getCctvDebugKeyLabel(activeStreamKeyRef.current),
453
+ nextStreamKey: getCctvDebugKeyLabel(streamKey),
454
+ },
455
+ source: "useRtcStream",
456
+ });
313
457
  streamRegistry.closeByIdentity(streamIdentityKey, streamKey);
314
458
  }
315
459
 
316
460
  activeStreamKeyRef.current = streamKey;
317
461
  activeStreamIdentityKeyRef.current = streamIdentityKey;
318
462
 
463
+ logCctvDebugEvent({
464
+ event: "stream-effect:start",
465
+ payload: {
466
+ ...debugBasePayload,
467
+ streamKey: getCctvDebugKeyLabel(streamKey),
468
+ },
469
+ source: "useRtcStream",
470
+ });
471
+
319
472
  streamRegistry.start({
320
473
  streamKey,
321
474
  identityKey: streamIdentityKey,
@@ -329,7 +482,17 @@ export function useCctvRtcStream({
329
482
  setConnectionState(snapshot.connectionState);
330
483
  setStreamError(snapshot.streamError);
331
484
  setStreaming(snapshot.isStreaming);
332
- if (snapshot.connectionState === "connected") setHasConnected(true);
485
+ if (snapshot.connectionState === "connected") {
486
+ logCctvDebugEvent({
487
+ event: "stream-effect:has-connected",
488
+ payload: {
489
+ ...debugBasePayload,
490
+ streamKey: getCctvDebugKeyLabel(streamKey),
491
+ },
492
+ source: "useRtcStream",
493
+ });
494
+ setHasConnected(true);
495
+ }
333
496
  if (snapshot.stream && currentVideo.srcObject !== snapshot.stream) {
334
497
  currentVideo.srcObject = snapshot.stream;
335
498
  }
@@ -346,10 +509,19 @@ export function useCctvRtcStream({
346
509
 
347
510
  // effect cleanup: current video attach만 해제하고 registry stream은 유지한다.
348
511
  return () => {
512
+ logCctvDebugEvent({
513
+ event: "stream-effect:detach-video",
514
+ payload: {
515
+ ...debugBasePayload,
516
+ streamKey: getCctvDebugKeyLabel(streamKey),
517
+ },
518
+ source: "useRtcStream",
519
+ });
349
520
  unsubscribe();
350
521
  detachVideo();
351
522
  };
352
523
  }, [
524
+ debugBasePayload,
353
525
  endpoint,
354
526
  streamIdentityKey,
355
527
  streamKey,
@@ -359,10 +531,56 @@ export function useCctvRtcStream({
359
531
  cam?.cam_online,
360
532
  ]);
361
533
 
534
+ const debugReconnectStateRef = useRef({
535
+ connectionState,
536
+ debugBasePayload,
537
+ isPostConnectedReconnectReady,
538
+ reconnectReason,
539
+ streamError,
540
+ });
541
+ debugReconnectStateRef.current = {
542
+ connectionState,
543
+ debugBasePayload,
544
+ isPostConnectedReconnectReady,
545
+ reconnectReason,
546
+ streamError,
547
+ };
548
+
362
549
  const reconnectStream = useCallback<CctvRtcReconnectTrigger>(
363
550
  async options => {
551
+ const debugState = debugReconnectStateRef.current;
552
+ logCctvDebugEvent({
553
+ event: "reconnect-stream:start",
554
+ payload: {
555
+ ...debugState.debugBasePayload,
556
+ connectionState: debugState.connectionState,
557
+ callerStack: getCctvDebugStackTrace(),
558
+ isPostConnectedReconnectReady:
559
+ debugState.isPostConnectedReconnectReady,
560
+ reconnectReason: debugState.reconnectReason,
561
+ streamError: debugState.streamError,
562
+ },
563
+ source: "useRtcStream",
564
+ });
364
565
  const result = await refetchRtcToken(options);
566
+ const resultDebugState = debugReconnectStateRef.current;
567
+ logCctvDebugEvent({
568
+ event: "reconnect-stream:token-result",
569
+ level: result.isSuccess ? "info" : "warn",
570
+ payload: {
571
+ ...resultDebugState.debugBasePayload,
572
+ isError: result.isError,
573
+ isSuccess: result.isSuccess,
574
+ status: result.status,
575
+ },
576
+ source: "useRtcStream",
577
+ });
365
578
  if (result.isSuccess && streamIdentityKey) {
579
+ logCctvDebugEvent({
580
+ event: "reconnect-stream:close-identity",
581
+ payload: resultDebugState.debugBasePayload,
582
+ source: "useRtcStream",
583
+ });
366
584
  streamRegistry.closeByIdentity(streamIdentityKey);
367
585
  }
368
586
  return result;
@@ -375,20 +593,15 @@ export function useCctvRtcStream({
375
593
  if (!hasConnected) return false;
376
594
  if (!streamIdentityKey) return false;
377
595
  if (isTokenLoading || isStreaming) return false;
378
- if (isTokenError || streamError) return true;
379
- if (connectionState === "failed") return true;
380
- if (connectionState === "disconnected") return isDisconnectedReconnectReady;
381
- if (connectionState === "closed") return true;
382
- return false;
596
+ if (!reconnectReason) return false;
597
+ return isPostConnectedReconnectReady;
383
598
  }, [
384
599
  cam?.cam_online,
385
- connectionState,
386
600
  hasConnected,
387
- isDisconnectedReconnectReady,
601
+ isPostConnectedReconnectReady,
388
602
  isStreaming,
389
- isTokenError,
390
603
  isTokenLoading,
391
- streamError,
604
+ reconnectReason,
392
605
  streamIdentityKey,
393
606
  ]);
394
607
 
@@ -407,6 +620,16 @@ export function useCctvRtcStream({
407
620
  return;
408
621
 
409
622
  lastAutoReconnectAtRef.current = now;
623
+ logCctvDebugEvent({
624
+ event: "focus:auto-reconnect",
625
+ payload: {
626
+ ...debugBasePayload,
627
+ canReconnect,
628
+ connectionState,
629
+ reconnectReason,
630
+ },
631
+ source: "useRtcStream",
632
+ });
410
633
  void reconnectStream();
411
634
  };
412
635
 
@@ -417,7 +640,13 @@ export function useCctvRtcStream({
417
640
  window.removeEventListener("focus", reconnectOnFocus);
418
641
  document.removeEventListener("visibilitychange", reconnectOnFocus);
419
642
  };
420
- }, [canReconnect, reconnectStream]);
643
+ }, [
644
+ canReconnect,
645
+ connectionState,
646
+ debugBasePayload,
647
+ reconnectReason,
648
+ reconnectStream,
649
+ ]);
421
650
 
422
651
  const liveState = useMemo(
423
652
  () =>
@@ -441,6 +670,40 @@ export function useCctvRtcStream({
441
670
  ],
442
671
  );
443
672
 
673
+ useEffect(() => {
674
+ logCctvDebugEvent({
675
+ event: "hook-state:update",
676
+ payload: {
677
+ ...debugBasePayload,
678
+ canReconnect,
679
+ connectionState,
680
+ displayConnectionState,
681
+ hasConnected,
682
+ isPostConnectedReconnectReady,
683
+ isStreaming,
684
+ isTokenError,
685
+ isTokenLoading,
686
+ liveState,
687
+ reconnectReason,
688
+ streamError,
689
+ },
690
+ source: "useRtcStream",
691
+ });
692
+ }, [
693
+ canReconnect,
694
+ connectionState,
695
+ debugBasePayload,
696
+ displayConnectionState,
697
+ hasConnected,
698
+ isPostConnectedReconnectReady,
699
+ isStreaming,
700
+ isTokenError,
701
+ isTokenLoading,
702
+ liveState,
703
+ reconnectReason,
704
+ streamError,
705
+ ]);
706
+
444
707
  useEffect(() => {
445
708
  const camId = cam?.cam_id;
446
709
  if (!camId) return;
@@ -88,7 +88,7 @@ export interface UseCctvRtcStreamState extends UseCctvRtcStreamError {
88
88
  * @property {boolean} isStreaming startWhepStream 진행 여부
89
89
  * @property {boolean} isTokenLoading 토큰 발급 요청 진행 여부
90
90
  * @property {boolean} isTokenError 토큰 발급 실패 여부
91
- * @property {boolean} canReconnect 재연결 가능한 종료 상태 여부
91
+ * @property {boolean} canReconnect UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
92
92
  * @property {() => Promise<void>} refetchToken 토큰 재발급 함수
93
93
  * @property {() => Promise<QueryObserverResult<API_Res_CctvRtcToken>>} reconnectStream 재연결 trigger 함수
94
94
  */
@@ -98,7 +98,10 @@ export interface UseCctvRtcStreamReturn extends UseCctvRtcStreamState {
98
98
  */
99
99
  videoRef: React.RefObject<HTMLVideoElement | null>;
100
100
  /**
101
- * 재연결 가능한 종료 상태 여부
101
+ * UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
102
+ * @desc
103
+ * 스트림 장애 감지 즉시 true가 되는 플래그가 아니라,
104
+ * 서비스 overlay가 reconnectStream()을 호출해도 되는 시점에 true가 된다.
102
105
  */
103
106
  canReconnect: boolean;
104
107
  /**
@@ -9,7 +9,7 @@ import type { CctvRtcReconnectTrigger } from "./hook";
9
9
  * @property {boolean | undefined} [isError] 영상 에러 상태 여부
10
10
  * @property {boolean | undefined} [isLive] 영상 live 상태 여부
11
11
  * @property {React.ReactNode} [overlayMessage] 기본 안내/에러 메시지 콘텐츠
12
- * @property {boolean | undefined} [canReconnect] 재연결 가능한 종료 상태 여부
12
+ * @property {boolean | undefined} [canReconnect] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
13
13
  * @property {CctvRtcReconnectTrigger | undefined} [reconnectStream] 재연결 trigger 함수
14
14
  */
15
15
  export interface CctvVideoRenderOverlayContext {
@@ -38,7 +38,10 @@ export interface CctvVideoRenderOverlayContext {
38
38
  */
39
39
  overlayMessage?: React.ReactNode;
40
40
  /**
41
- * 재연결 가능한 종료 상태 여부
41
+ * UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
42
+ * @desc
43
+ * custom overlay에서 자동 재연결을 구현할 때 이 값이 true인 경우에만
44
+ * reconnectStream()을 호출해야 한다.
42
45
  */
43
46
  canReconnect?: boolean;
44
47
  /**
@@ -126,7 +129,7 @@ export interface CctvVideoOverlayFooterProps {
126
129
  * @property {boolean} [isError] 에러 상태 여부
127
130
  * @property {boolean} [isLive] 영상이 정상적으로 재생 중인지 여부
128
131
  * @property {React.ReactNode} [overlayMessage] 에러/안내 메시지 콘텐츠
129
- * @property {boolean} [canReconnect] 재연결 가능한 종료 상태 여부
132
+ * @property {boolean} [canReconnect] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
130
133
  * @property {CctvRtcReconnectTrigger} [reconnectStream] 재연결 trigger 함수
131
134
  */
132
135
  export interface CctvVideoStateProps {
@@ -143,7 +146,7 @@ export interface CctvVideoStateProps {
143
146
  */
144
147
  overlayMessage?: React.ReactNode;
145
148
  /**
146
- * 재연결 가능한 종료 상태 여부
149
+ * UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
147
150
  */
148
151
  canReconnect?: boolean;
149
152
  /**
@@ -11,7 +11,7 @@ import type { UseCctvRtcStreamError, UseCctvRtcStreamState } from "./hook";
11
11
  * @property {boolean} isTokenLoading 토큰 발급 요청 진행 여부
12
12
  * @property {boolean} isTokenError 토큰 발급 실패 여부
13
13
  * @property {string | null} streamError 스트림 오류 메시지
14
- * @property {boolean} [canReconnect] 재연결 가능한 종료 상태 여부
14
+ * @property {boolean} [canReconnect] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
15
15
  */
16
16
  export interface CctvVideoOverlayMessageParams extends UseCctvRtcStreamState {
17
17
  /**
@@ -27,7 +27,7 @@ export interface CctvVideoOverlayMessageParams extends UseCctvRtcStreamState {
27
27
  */
28
28
  isFetching?: boolean;
29
29
  /**
30
- * 재연결 가능한 종료 상태 여부
30
+ * UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
31
31
  */
32
32
  canReconnect?: boolean;
33
33
  }
@@ -53,7 +53,7 @@ export interface CctvVideoLiveParams extends UseCctvRtcStreamState {
53
53
  * @property {CctvCompanyCameraData} [cam] 카메라 데이터
54
54
  * @property {boolean} isTokenError 토큰 발급 실패 여부
55
55
  * @property {string | null} streamError 스트림 오류 메시지
56
- * @property {boolean} [canReconnect] 재연결 가능한 종료 상태 여부
56
+ * @property {boolean} [canReconnect] UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
57
57
  */
58
58
  export interface CctvVideoErrorParams extends UseCctvRtcStreamError {
59
59
  /**
@@ -61,7 +61,7 @@ export interface CctvVideoErrorParams extends UseCctvRtcStreamError {
61
61
  */
62
62
  cam?: CctvCompanyCameraData;
63
63
  /**
64
- * 재연결 가능한 종료 상태 여부
64
+ * UDS 내부 grace/stagger를 통과해 재연결 호출이 가능한지 여부
65
65
  */
66
66
  canReconnect?: boolean;
67
67
  }
@@ -0,0 +1,179 @@
1
+ "use client";
2
+
3
+ export type CctvDebugEventLevel = "debug" | "info" | "warn" | "error";
4
+
5
+ export interface CctvDebugEvent {
6
+ at: string;
7
+ event: string;
8
+ level: CctvDebugEventLevel;
9
+ payload?: Record<string, unknown>;
10
+ seq: number;
11
+ source: string;
12
+ }
13
+
14
+ export interface CctvDebugBuffer {
15
+ clear: () => void;
16
+ dump: () => CctvDebugEvent[];
17
+ enabled: true;
18
+ events: CctvDebugEvent[];
19
+ limit: number;
20
+ sequence: number;
21
+ }
22
+
23
+ declare global {
24
+ interface Window {
25
+ __UDS_CCTV_DEBUG__?: CctvDebugBuffer;
26
+ }
27
+ }
28
+
29
+ const DEBUG_QUERY_KEYS = ["udsCctvDebug", "cctvDebug"] as const;
30
+ const DEBUG_STORAGE_KEYS = ["uds:cctv:debug", "UDS_CCTV_DEBUG"] as const;
31
+ const DEBUG_ENABLED_VALUES = new Set(["1", "true", "yes", "on", "debug"]);
32
+ const DEBUG_BUFFER_LIMIT = 1000;
33
+
34
+ const isBrowser = (): boolean =>
35
+ typeof window !== "undefined" && typeof document !== "undefined";
36
+
37
+ const isDebugValueEnabled = (value: string | null): boolean =>
38
+ value === "" ||
39
+ (typeof value === "string" && DEBUG_ENABLED_VALUES.has(value));
40
+
41
+ /**
42
+ * identity/stream key를 원문 대신 추적 가능한 hash label로 바꾼다.
43
+ */
44
+ export const getCctvDebugKeyLabel = (
45
+ value: string | null | undefined,
46
+ ): string | null => {
47
+ if (!value) return null;
48
+
49
+ let hash = 0;
50
+ for (let i = 0; i < value.length; i += 1) {
51
+ hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
52
+ }
53
+
54
+ return `hash:${hash.toString(36)}|len:${value.length}`;
55
+ };
56
+
57
+ /**
58
+ * endpoint는 query string을 제거한 origin/path만 기록한다.
59
+ */
60
+ export const getCctvDebugUrlLabel = (
61
+ value: string | null | undefined,
62
+ ): string | null => {
63
+ if (!value) return null;
64
+
65
+ try {
66
+ const url = new URL(value);
67
+ return `${url.origin}${url.pathname}`;
68
+ } catch {
69
+ return value.split("?")[0] ?? null;
70
+ }
71
+ };
72
+
73
+ /**
74
+ * CCTV debug logging 활성화 여부를 확인한다.
75
+ * @desc
76
+ * production default는 off다. `?udsCctvDebug=1` 또는
77
+ * `localStorage.setItem("uds:cctv:debug", "1")`로 runtime에서 켤 수 있다.
78
+ */
79
+ export const isCctvDebugEnabled = (): boolean => {
80
+ if (!isBrowser()) return false;
81
+
82
+ const query = new URLSearchParams(window.location.search);
83
+ for (const key of DEBUG_QUERY_KEYS) {
84
+ if (query.has(key) && isDebugValueEnabled(query.get(key))) return true;
85
+ }
86
+
87
+ for (const key of DEBUG_STORAGE_KEYS) {
88
+ try {
89
+ if (isDebugValueEnabled(window.localStorage.getItem(key))) return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ return false;
96
+ };
97
+
98
+ /**
99
+ * reconnect 호출 주체를 좁히기 위한 stack trace 일부를 만든다.
100
+ */
101
+ export const getCctvDebugStackTrace = (): string[] | null => {
102
+ if (!isCctvDebugEnabled()) return null;
103
+
104
+ const stack = new Error().stack;
105
+ if (!stack) return null;
106
+
107
+ return stack
108
+ .split("\n")
109
+ .slice(2, 8)
110
+ .map(line => line.trim());
111
+ };
112
+
113
+ const getCctvDebugBuffer = (): CctvDebugBuffer | null => {
114
+ if (!isCctvDebugEnabled()) return null;
115
+
116
+ const existingBuffer = window.__UDS_CCTV_DEBUG__;
117
+ if (existingBuffer) return existingBuffer;
118
+
119
+ const buffer: CctvDebugBuffer = {
120
+ enabled: true,
121
+ events: [],
122
+ limit: DEBUG_BUFFER_LIMIT,
123
+ sequence: 0,
124
+ clear: () => {
125
+ buffer.events.splice(0, buffer.events.length);
126
+ buffer.sequence = 0;
127
+ },
128
+ dump: () => [...buffer.events],
129
+ };
130
+
131
+ window.__UDS_CCTV_DEBUG__ = buffer;
132
+ return buffer;
133
+ };
134
+
135
+ /**
136
+ * CCTV runtime 진단 이벤트를 ring buffer와 console에 남긴다.
137
+ * @desc
138
+ * 토큰 값은 payload에 넣지 않는다. 이 logger는 debug flag가 켜진 경우에만 동작한다.
139
+ */
140
+ export const logCctvDebugEvent = ({
141
+ event,
142
+ level = "debug",
143
+ payload,
144
+ source,
145
+ }: {
146
+ event: string;
147
+ level?: CctvDebugEventLevel;
148
+ payload?: Record<string, unknown>;
149
+ source: string;
150
+ }): void => {
151
+ const buffer = getCctvDebugBuffer();
152
+ if (!buffer) return;
153
+
154
+ const entry: CctvDebugEvent = {
155
+ at: new Date().toISOString(),
156
+ event,
157
+ level,
158
+ payload,
159
+ seq: buffer.sequence + 1,
160
+ source,
161
+ };
162
+
163
+ buffer.sequence = entry.seq;
164
+ buffer.events.push(entry);
165
+ if (buffer.events.length > buffer.limit) {
166
+ buffer.events.splice(0, buffer.events.length - buffer.limit);
167
+ }
168
+
169
+ const logger =
170
+ level === "error"
171
+ ? console.error
172
+ : level === "warn"
173
+ ? console.warn
174
+ : level === "info"
175
+ ? console.info
176
+ : console.debug;
177
+
178
+ logger.call(console, "[UDS:CCTV]", entry);
179
+ };
@@ -62,6 +62,10 @@ export function getOverlayMessage({
62
62
  if (canReconnect && RTC_SESSION_ENDED_RECONNECT_STATES.has(connectionState)) {
63
63
  return CCTV_MESSAGE.sessionEnded;
64
64
  }
65
+ // grace/stagger가 끝난 disconnected는 더 이상 준비 상태가 아니라 재연결 대상 장애다.
66
+ if (canReconnect && connectionState === "disconnected") {
67
+ return CCTV_MESSAGE.offline;
68
+ }
65
69
  if (isTokenLoading || isStreaming) return CCTV_MESSAGE.preparing;
66
70
  if (RTC_PREPARING_STATES.has(connectionState)) return CCTV_MESSAGE.preparing;
67
71
  return null;