@uniai-fe/uds-templates 0.6.15 → 0.6.16

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/dist/styles.css CHANGED
@@ -1796,7 +1796,7 @@
1796
1796
  flex-direction: column;
1797
1797
  align-items: center;
1798
1798
  justify-content: center;
1799
- gap: 2px;
1799
+ gap: var(--spacing-gap-4);
1800
1800
  }
1801
1801
 
1802
1802
  .cctv-video-loading-icon {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.6.15",
3
+ "version": "0.6.16",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -1,12 +1,6 @@
1
1
  "use client";
2
2
 
3
3
  import { startWhepStream, type WhepStreamHandle } from "@uniai-fe/util-rtc";
4
- import {
5
- getCctvDebugJwtMetadata,
6
- getCctvDebugKeyLabel,
7
- getCctvDebugUrlLabel,
8
- logCctvDebugEvent,
9
- } from "../utils/debug";
10
4
 
11
5
  interface CctvRtcStreamSnapshot {
12
6
  connectionState: RTCPeerConnectionState;
@@ -32,8 +26,6 @@ interface CctvRtcStreamStartParams {
32
26
  video: HTMLVideoElement;
33
27
  }
34
28
 
35
- const WHEP_ERROR_BODY_PREVIEW_LIMIT = 1000;
36
-
37
29
  const getEntrySnapshot = (
38
30
  entry?: CctvRtcStreamEntry,
39
31
  ): CctvRtcStreamSnapshot => ({
@@ -63,69 +55,7 @@ const notifyEntry = (entry: CctvRtcStreamEntry) => {
63
55
  entry.listeners.forEach(listener => listener(snapshot));
64
56
  };
65
57
 
66
- const getWhepResponseBodyPreview = async (
67
- response: Response,
68
- ): Promise<string | null> => {
69
- try {
70
- const text = await response.clone().text();
71
- return text.slice(0, WHEP_ERROR_BODY_PREVIEW_LIMIT);
72
- } catch {
73
- return null;
74
- }
75
- };
76
-
77
- /**
78
- * WHEP 실패 body를 startWhepStream 외부에서 관찰하기 위한 fetch wrapper.
79
- * @desc
80
- * 성공 응답은 그대로 통과시키고, 실패 응답에서만 clone body를 읽어 debug log에 남긴다.
81
- */
82
- const createCctvDebugWhepFetcher =
83
- ({
84
- endpoint,
85
- identityKey,
86
- streamKey,
87
- token,
88
- }: {
89
- endpoint: string;
90
- identityKey: string;
91
- streamKey: string;
92
- token: string;
93
- }): typeof fetch =>
94
- async (input, init) => {
95
- const response = await fetch(input, init);
96
- if (response.ok) return response;
97
-
98
- logCctvDebugEvent({
99
- event: "whep:response-error",
100
- level: "error",
101
- payload: {
102
- endpoint: getCctvDebugUrlLabel(endpoint),
103
- identityKey: getCctvDebugKeyLabel(identityKey),
104
- responseBody: await getWhepResponseBodyPreview(response),
105
- status: response.status,
106
- statusText: response.statusText,
107
- streamKey: getCctvDebugKeyLabel(streamKey),
108
- token: getCctvDebugJwtMetadata(token),
109
- },
110
- source: "streamRegistry",
111
- });
112
-
113
- return response;
114
- };
115
-
116
58
  const closeEntry = (entry: CctvRtcStreamEntry) => {
117
- logCctvDebugEvent({
118
- event: "entry:close",
119
- payload: {
120
- attachedVideoCount: entry.attachedVideos.size,
121
- connectionState: entry.connectionState,
122
- identityKey: getCctvDebugKeyLabel(entry.identityKey),
123
- isStreaming: entry.isStreaming,
124
- streamKey: getCctvDebugKeyLabel(entry.streamKey),
125
- streamTrackCount: entry.stream?.getTracks().length ?? 0,
126
- },
127
- source: "streamRegistry",
128
- });
129
59
  entry.controller?.abort();
130
60
  entry.controller = null;
131
61
  entry.handle?.close();
@@ -187,15 +117,6 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
187
117
  entries.clear();
188
118
  },
189
119
  closeByIdentity(identityKey, exceptStreamKey) {
190
- logCctvDebugEvent({
191
- event: "identity:close",
192
- payload: {
193
- entryCount: entries.size,
194
- exceptStreamKey: getCctvDebugKeyLabel(exceptStreamKey),
195
- identityKey: getCctvDebugKeyLabel(identityKey),
196
- },
197
- source: "streamRegistry",
198
- });
199
120
  entries.forEach(entry => {
200
121
  if (
201
122
  entry.identityKey === identityKey &&
@@ -211,21 +132,10 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
211
132
  start({ endpoint, identityKey, streamKey, token, video }) {
212
133
  const existingEntry = entries.get(streamKey);
213
134
  if (existingEntry) {
214
- logCctvDebugEvent({
215
- event: "start:reuse-existing",
216
- payload: {
217
- connectionState: existingEntry.connectionState,
218
- identityKey: getCctvDebugKeyLabel(identityKey),
219
- isStreaming: existingEntry.isStreaming,
220
- streamKey: getCctvDebugKeyLabel(streamKey),
221
- },
222
- source: "streamRegistry",
223
- });
224
135
  return;
225
136
  }
226
137
 
227
138
  const controller = new AbortController();
228
- const startedAt = Date.now();
229
139
  const entry: CctvRtcStreamEntry = {
230
140
  attachedVideos: new Set([video]),
231
141
  connectionState: "connecting",
@@ -246,58 +156,18 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
246
156
  video.playsInline = true;
247
157
  video.autoplay = true;
248
158
  }
249
- logCctvDebugEvent({
250
- event: "start:create-entry",
251
- payload: {
252
- attachedVideoCount: entry.attachedVideos.size,
253
- endpoint: getCctvDebugUrlLabel(endpoint),
254
- identityKey: getCctvDebugKeyLabel(identityKey),
255
- streamKey: getCctvDebugKeyLabel(streamKey),
256
- token: getCctvDebugJwtMetadata(token),
257
- },
258
- source: "streamRegistry",
259
- });
260
159
 
261
160
  startWhepStream({
262
161
  endpoint,
263
- fetcher: createCctvDebugWhepFetcher({
264
- endpoint,
265
- identityKey,
266
- streamKey,
267
- token,
268
- }),
269
162
  token,
270
163
  video,
271
164
  signal: controller.signal,
272
165
  onConnectionStateChange: state => {
273
166
  entry.connectionState = state;
274
- logCctvDebugEvent({
275
- event: "connection-state:change",
276
- payload: {
277
- elapsedMs: Date.now() - startedAt,
278
- identityKey: getCctvDebugKeyLabel(identityKey),
279
- state,
280
- streamKey: getCctvDebugKeyLabel(streamKey),
281
- },
282
- source: "streamRegistry",
283
- });
284
167
  notifyEntry(entry);
285
168
  },
286
169
  onTrack: event => {
287
170
  entry.stream = event.streams[0] ?? null;
288
- logCctvDebugEvent({
289
- event: "track:received",
290
- payload: {
291
- elapsedMs: Date.now() - startedAt,
292
- identityKey: getCctvDebugKeyLabel(identityKey),
293
- streamId: entry.stream?.id ?? null,
294
- streamKey: getCctvDebugKeyLabel(streamKey),
295
- trackId: event.track.id,
296
- trackKind: event.track.kind,
297
- trackReadyState: event.track.readyState,
298
- },
299
- source: "streamRegistry",
300
- });
301
171
  entry.attachedVideos.forEach(attachedVideo =>
302
172
  attachVideo(attachedVideo, entry.stream),
303
173
  );
@@ -307,16 +177,6 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
307
177
  .then(handle => {
308
178
  entry.handle = handle;
309
179
  entry.isStreaming = false;
310
- logCctvDebugEvent({
311
- event: "start:resolved",
312
- level: "info",
313
- payload: {
314
- elapsedMs: Date.now() - startedAt,
315
- identityKey: getCctvDebugKeyLabel(identityKey),
316
- streamKey: getCctvDebugKeyLabel(streamKey),
317
- },
318
- source: "streamRegistry",
319
- });
320
180
  notifyEntry(entry);
321
181
  })
322
182
  .catch(error => {
@@ -328,17 +188,6 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
328
188
  error instanceof Error
329
189
  ? error.message
330
190
  : "스트림 연결에 실패했습니다.";
331
- logCctvDebugEvent({
332
- event: "start:rejected",
333
- level: "error",
334
- payload: {
335
- elapsedMs: Date.now() - startedAt,
336
- errorMessage: entry.streamError,
337
- identityKey: getCctvDebugKeyLabel(identityKey),
338
- streamKey: getCctvDebugKeyLabel(streamKey),
339
- },
340
- source: "streamRegistry",
341
- });
342
191
  entry.attachedVideos.forEach(attachedVideo =>
343
192
  clearVideoIfAttachedStream(attachedVideo, entry.stream),
344
193
  );
@@ -19,11 +19,6 @@ 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";
27
22
  import { getIsLive } from "../utils/video-state";
28
23
  import { useFormContext, useWatch } from "react-hook-form";
29
24
 
@@ -236,23 +231,9 @@ export function useCctvRtcStream({
236
231
  return [tokenUsername, cam.company_id, cam.cam_id, endpoint].join("|");
237
232
  }, [cam?.cam_id, cam?.company_id, endpoint, tokenUsername]);
238
233
 
239
- const debugBasePayload = useMemo(
240
- () => ({
241
- camId: cam?.cam_id,
242
- companyId: cam?.company_id,
243
- streamIdentityKey: getCctvDebugKeyLabel(streamIdentityKey),
244
- }),
245
- [cam?.cam_id, cam?.company_id, streamIdentityKey],
246
- );
247
-
248
234
  useEffect(() => {
249
- logCctvDebugEvent({
250
- event: "identity:reset-has-connected",
251
- payload: debugBasePayload,
252
- source: "useRtcStream",
253
- });
254
235
  setHasConnected(false);
255
- }, [debugBasePayload, streamIdentityKey]);
236
+ }, [streamIdentityKey]);
256
237
 
257
238
  const reconnectReason = useMemo(
258
239
  () =>
@@ -282,45 +263,14 @@ export function useCctvRtcStream({
282
263
  `${streamIdentityKey}|${reconnectReason}`,
283
264
  );
284
265
 
285
- logCctvDebugEvent({
286
- event: "reconnect-gate:timer-start",
287
- payload: {
288
- ...debugBasePayload,
289
- isStreaming,
290
- isTokenLoading,
291
- reconnectDelayMs,
292
- reconnectReason,
293
- },
294
- source: "useRtcStream",
295
- });
296
-
297
266
  const timeout = setTimeout(() => {
298
267
  setPostConnectedReconnectReady(true);
299
- logCctvDebugEvent({
300
- event: "reconnect-gate:ready",
301
- payload: {
302
- ...debugBasePayload,
303
- reconnectDelayMs,
304
- reconnectReason,
305
- },
306
- source: "useRtcStream",
307
- });
308
268
  }, reconnectDelayMs);
309
269
 
310
270
  return () => {
311
271
  clearTimeout(timeout);
312
- logCctvDebugEvent({
313
- event: "reconnect-gate:timer-cancel",
314
- payload: {
315
- ...debugBasePayload,
316
- reconnectDelayMs,
317
- reconnectReason,
318
- },
319
- source: "useRtcStream",
320
- });
321
272
  };
322
273
  }, [
323
- debugBasePayload,
324
274
  hasConnected,
325
275
  isStreaming,
326
276
  isTokenLoading,
@@ -357,36 +307,6 @@ export function useCctvRtcStream({
357
307
  ? false
358
308
  : isTokenLoading;
359
309
 
360
- const skippedDisconnectedDebugKeyRef = useRef<string | null>(null);
361
-
362
- useEffect(() => {
363
- if (
364
- connectionState !== "disconnected" ||
365
- !hasConnected ||
366
- !streamIdentityKey
367
- ) {
368
- if (connectionState !== "disconnected") {
369
- skippedDisconnectedDebugKeyRef.current = null;
370
- }
371
- return;
372
- }
373
-
374
- const skipKey = `${streamIdentityKey}|${activeStreamKeyRef.current ?? ""}`;
375
- if (skippedDisconnectedDebugKeyRef.current === skipKey) return;
376
-
377
- skippedDisconnectedDebugKeyRef.current = skipKey;
378
- logCctvDebugEvent({
379
- event: "reconnect-gate:skip-disconnected",
380
- payload: {
381
- ...debugBasePayload,
382
- connectionState,
383
- reason: "disconnected-observed-only",
384
- streamKey: getCctvDebugKeyLabel(activeStreamKeyRef.current),
385
- },
386
- source: "useRtcStream",
387
- });
388
- }, [connectionState, debugBasePayload, hasConnected, streamIdentityKey]);
389
-
390
310
  const hasReusableRegistryStream = useMemo(() => {
391
311
  if (!streamKeyCandidate) return false;
392
312
 
@@ -436,19 +356,9 @@ export function useCctvRtcStream({
436
356
  }
437
357
 
438
358
  staleTokenRefreshKeyRef.current = streamKeyCandidate;
439
- logCctvDebugEvent({
440
- event: "token:stale-refetch",
441
- payload: {
442
- ...debugBasePayload,
443
- dataUpdatedAt: tokenQuery.dataUpdatedAt,
444
- streamKey: getCctvDebugKeyLabel(streamKeyCandidate),
445
- },
446
- source: "useRtcStream",
447
- });
448
359
  void refetchRtcToken();
449
360
  }, [
450
361
  canUseTokenForStream,
451
- debugBasePayload,
452
362
  refetchRtcToken,
453
363
  streamKeyCandidate,
454
364
  tokenQuery.data?.token,
@@ -463,15 +373,6 @@ export function useCctvRtcStream({
463
373
 
464
374
  // 토큰/카메라 에러로 전환되면 직전 MediaStream이 화면에 남지 않도록 비운다.
465
375
  if (tokenQuery.isError || !cam?.cam_online) {
466
- logCctvDebugEvent({
467
- event: "stream-effect:cleanup-token-or-offline",
468
- payload: {
469
- ...debugBasePayload,
470
- camOnline: cam?.cam_online,
471
- isTokenError: tokenQuery.isError,
472
- },
473
- source: "useRtcStream",
474
- });
475
376
  if (streamIdentityKey) {
476
377
  streamRegistry.closeByIdentity(streamIdentityKey);
477
378
  }
@@ -497,42 +398,12 @@ export function useCctvRtcStream({
497
398
  if (!previousStreamKey || didClosePreviousStream) return;
498
399
 
499
400
  didClosePreviousStream = true;
500
- logCctvDebugEvent({
501
- event: "stream-effect:close-previous-after-track",
502
- payload: {
503
- ...debugBasePayload,
504
- previousStreamKey: getCctvDebugKeyLabel(previousStreamKey),
505
- streamKey: getCctvDebugKeyLabel(streamKey),
506
- },
507
- source: "useRtcStream",
508
- });
509
401
  streamRegistry.closeByIdentity(streamIdentityKey, streamKey);
510
402
  };
511
403
 
512
- if (previousStreamKey) {
513
- logCctvDebugEvent({
514
- event: "stream-effect:prepare-replacement",
515
- payload: {
516
- ...debugBasePayload,
517
- previousStreamKey: getCctvDebugKeyLabel(previousStreamKey),
518
- streamKey: getCctvDebugKeyLabel(streamKey),
519
- },
520
- source: "useRtcStream",
521
- });
522
- }
523
-
524
404
  activeStreamKeyRef.current = streamKey;
525
405
  activeStreamIdentityKeyRef.current = streamIdentityKey;
526
406
 
527
- logCctvDebugEvent({
528
- event: "stream-effect:start",
529
- payload: {
530
- ...debugBasePayload,
531
- streamKey: getCctvDebugKeyLabel(streamKey),
532
- },
533
- source: "useRtcStream",
534
- });
535
-
536
407
  streamRegistry.start({
537
408
  streamKey,
538
409
  identityKey: streamIdentityKey,
@@ -547,14 +418,6 @@ export function useCctvRtcStream({
547
418
  setStreamError(snapshot.streamError);
548
419
  setStreaming(snapshot.isStreaming);
549
420
  if (snapshot.connectionState === "connected") {
550
- logCctvDebugEvent({
551
- event: "stream-effect:has-connected",
552
- payload: {
553
- ...debugBasePayload,
554
- streamKey: getCctvDebugKeyLabel(streamKey),
555
- },
556
- source: "useRtcStream",
557
- });
558
421
  setHasConnected(true);
559
422
  }
560
423
  if (snapshot.stream) {
@@ -579,19 +442,10 @@ export function useCctvRtcStream({
579
442
 
580
443
  // effect cleanup: current video attach만 해제하고 registry stream은 유지한다.
581
444
  return () => {
582
- logCctvDebugEvent({
583
- event: "stream-effect:detach-video",
584
- payload: {
585
- ...debugBasePayload,
586
- streamKey: getCctvDebugKeyLabel(streamKey),
587
- },
588
- source: "useRtcStream",
589
- });
590
445
  unsubscribe();
591
446
  detachVideo();
592
447
  };
593
448
  }, [
594
- debugBasePayload,
595
449
  endpoint,
596
450
  streamIdentityKey,
597
451
  streamKey,
@@ -601,52 +455,8 @@ export function useCctvRtcStream({
601
455
  cam?.cam_online,
602
456
  ]);
603
457
 
604
- const debugReconnectStateRef = useRef({
605
- connectionState,
606
- debugBasePayload,
607
- isPostConnectedReconnectReady,
608
- reconnectReason,
609
- streamError,
610
- });
611
- debugReconnectStateRef.current = {
612
- connectionState,
613
- debugBasePayload,
614
- isPostConnectedReconnectReady,
615
- reconnectReason,
616
- streamError,
617
- };
618
-
619
458
  const reconnectStream = useCallback<CctvRtcReconnectTrigger>(
620
- async options => {
621
- const debugState = debugReconnectStateRef.current;
622
- logCctvDebugEvent({
623
- event: "reconnect-stream:start",
624
- payload: {
625
- ...debugState.debugBasePayload,
626
- connectionState: debugState.connectionState,
627
- callerStack: getCctvDebugStackTrace(),
628
- isPostConnectedReconnectReady:
629
- debugState.isPostConnectedReconnectReady,
630
- reconnectReason: debugState.reconnectReason,
631
- streamError: debugState.streamError,
632
- },
633
- source: "useRtcStream",
634
- });
635
- const result = await refetchRtcToken(options);
636
- const resultDebugState = debugReconnectStateRef.current;
637
- logCctvDebugEvent({
638
- event: "reconnect-stream:token-result",
639
- level: result.isSuccess ? "info" : "warn",
640
- payload: {
641
- ...resultDebugState.debugBasePayload,
642
- isError: result.isError,
643
- isSuccess: result.isSuccess,
644
- status: result.status,
645
- },
646
- source: "useRtcStream",
647
- });
648
- return result;
649
- },
459
+ options => refetchRtcToken(options),
650
460
  [refetchRtcToken],
651
461
  );
652
462
 
@@ -682,16 +492,6 @@ export function useCctvRtcStream({
682
492
  return;
683
493
 
684
494
  lastAutoReconnectAtRef.current = now;
685
- logCctvDebugEvent({
686
- event: "focus:auto-reconnect",
687
- payload: {
688
- ...debugBasePayload,
689
- canReconnect,
690
- connectionState,
691
- reconnectReason,
692
- },
693
- source: "useRtcStream",
694
- });
695
495
  void reconnectStream();
696
496
  };
697
497
 
@@ -702,13 +502,7 @@ export function useCctvRtcStream({
702
502
  window.removeEventListener("focus", reconnectOnFocus);
703
503
  document.removeEventListener("visibilitychange", reconnectOnFocus);
704
504
  };
705
- }, [
706
- canReconnect,
707
- connectionState,
708
- debugBasePayload,
709
- reconnectReason,
710
- reconnectStream,
711
- ]);
505
+ }, [canReconnect, reconnectStream]);
712
506
 
713
507
  const liveState = useMemo(
714
508
  () =>
@@ -732,48 +526,6 @@ export function useCctvRtcStream({
732
526
  ],
733
527
  );
734
528
 
735
- useEffect(() => {
736
- logCctvDebugEvent({
737
- event: "hook-state:update",
738
- payload: {
739
- ...debugBasePayload,
740
- canReconnect,
741
- connectionState,
742
- displayConnectionState,
743
- hasConnected,
744
- isPostConnectedReplacementLoading,
745
- isPostConnectedRecoverableState,
746
- isPostConnectedReconnectReady,
747
- displayIsStreaming,
748
- displayIsTokenLoading,
749
- isStreaming,
750
- isTokenError,
751
- isTokenLoading,
752
- liveState,
753
- reconnectReason,
754
- streamError,
755
- },
756
- source: "useRtcStream",
757
- });
758
- }, [
759
- canReconnect,
760
- connectionState,
761
- debugBasePayload,
762
- displayConnectionState,
763
- displayIsStreaming,
764
- displayIsTokenLoading,
765
- hasConnected,
766
- isPostConnectedReplacementLoading,
767
- isPostConnectedRecoverableState,
768
- isPostConnectedReconnectReady,
769
- isStreaming,
770
- isTokenError,
771
- isTokenLoading,
772
- liveState,
773
- reconnectReason,
774
- streamError,
775
- ]);
776
-
777
529
  useEffect(() => {
778
530
  const camId = cam?.cam_id;
779
531
  if (!camId) return;
@@ -137,7 +137,7 @@
137
137
  flex-direction: column;
138
138
  align-items: center;
139
139
  justify-content: center;
140
- gap: 2px;
140
+ gap: var(--spacing-gap-4);
141
141
  }
142
142
  .cctv-video-loading-icon {
143
143
  flex: 0 0 auto;
@@ -1,461 +0,0 @@
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 CctvDebugReconnectStartSummary {
15
- at: string;
16
- caller: string | null;
17
- camId: string | null;
18
- connectionState: string | null;
19
- isPostConnectedReconnectReady: boolean | null;
20
- reconnectReason: string | null;
21
- }
22
-
23
- export interface CctvDebugSummary {
24
- connectionStateCounts: Record<string, number>;
25
- eventCounts: Record<string, number>;
26
- first: string | null;
27
- generatedAt: string;
28
- last: string | null;
29
- reconnectCallers: Record<string, number>;
30
- reconnectReasons: Record<string, number>;
31
- reconnectStarts: CctvDebugReconnectStartSummary[];
32
- total: number;
33
- }
34
-
35
- export interface CctvDebugJwtMetadata {
36
- expiresAt: string | null;
37
- expiresInSec: number | null;
38
- headerAlg: string | null;
39
- headerKid: string | null;
40
- tokenLabel: string | null;
41
- tokenPartCount: number;
42
- }
43
-
44
- export interface CctvDebugBuffer {
45
- clear: () => void;
46
- dump: () => CctvDebugEvent[];
47
- enabled: true;
48
- events: CctvDebugEvent[];
49
- filter: (keywords?: readonly string[]) => CctvDebugEvent[];
50
- limit: number;
51
- sequence: number;
52
- summary: () => CctvDebugSummary;
53
- }
54
-
55
- declare global {
56
- interface Window {
57
- __UDS_CCTV_DEBUG__?: CctvDebugBuffer;
58
- }
59
- }
60
-
61
- const DEBUG_QUERY_KEYS = ["udsCctvDebug", "cctvDebug"] as const;
62
- const DEBUG_STORAGE_KEYS = ["uds:cctv:debug", "UDS_CCTV_DEBUG"] as const;
63
- const DEBUG_CONSOLE_QUERY_KEYS = [
64
- "udsCctvDebugConsole",
65
- "cctvDebugConsole",
66
- ] as const;
67
- const DEBUG_CONSOLE_STORAGE_KEYS = [
68
- "uds:cctv:debug:console",
69
- "UDS_CCTV_DEBUG_CONSOLE",
70
- ] as const;
71
- const DEBUG_ENABLED_VALUES = new Set(["1", "true", "yes", "on", "debug"]);
72
- const DEBUG_DISABLED_VALUES = new Set(["0", "false", "no", "off"]);
73
- const DEBUG_BUFFER_LIMIT = 1000;
74
- const DEBUG_DEFAULT_FILTER_KEYWORDS = [
75
- "reconnect",
76
- "close",
77
- "rejected",
78
- "connection-state:change",
79
- "track:received",
80
- ] as const;
81
-
82
- const isBrowser = (): boolean =>
83
- typeof window !== "undefined" && typeof document !== "undefined";
84
-
85
- const getDebugValueOverride = (value: string | null): boolean | null => {
86
- if (value === "") return true;
87
- if (typeof value !== "string") return null;
88
-
89
- const normalizedValue = value.toLowerCase();
90
- if (DEBUG_ENABLED_VALUES.has(normalizedValue)) return true;
91
- if (DEBUG_DISABLED_VALUES.has(normalizedValue)) return false;
92
- return null;
93
- };
94
-
95
- const getDebugQueryOverride = (keys: readonly string[]): boolean | null => {
96
- const query = new URLSearchParams(window.location.search);
97
- for (const key of keys) {
98
- if (!query.has(key)) continue;
99
-
100
- const override = getDebugValueOverride(query.get(key));
101
- if (override !== null) return override;
102
- }
103
-
104
- return null;
105
- };
106
-
107
- const getDebugStorageOverride = (keys: readonly string[]): boolean | null => {
108
- for (const key of keys) {
109
- try {
110
- const override = getDebugValueOverride(window.localStorage.getItem(key));
111
- if (override !== null) return override;
112
- } catch {
113
- return false;
114
- }
115
- }
116
-
117
- return null;
118
- };
119
-
120
- const getDebugConsoleOverride = (): boolean | null => {
121
- const queryOverride = getDebugQueryOverride(DEBUG_CONSOLE_QUERY_KEYS);
122
- if (queryOverride !== null) return queryOverride;
123
-
124
- return getDebugStorageOverride(DEBUG_CONSOLE_STORAGE_KEYS);
125
- };
126
-
127
- const isLocalhostDebugDefaultEnabled = (): boolean => {
128
- const { hostname } = window.location;
129
- return (
130
- hostname === "localhost" ||
131
- hostname === "127.0.0.1" ||
132
- hostname === "[::1]" ||
133
- hostname === "::1" ||
134
- hostname.endsWith(".localhost")
135
- );
136
- };
137
-
138
- const getPayloadString = (
139
- payload: Record<string, unknown> | undefined,
140
- key: string,
141
- ): string | null => {
142
- const value = payload?.[key];
143
- return typeof value === "string" ? value : null;
144
- };
145
-
146
- const getPayloadBoolean = (
147
- payload: Record<string, unknown> | undefined,
148
- key: string,
149
- ): boolean | null => {
150
- const value = payload?.[key];
151
- return typeof value === "boolean" ? value : null;
152
- };
153
-
154
- const getReconnectCaller = (
155
- payload: Record<string, unknown> | undefined,
156
- ): string | null => {
157
- const callerStack = payload?.callerStack;
158
- const stackLines = Array.isArray(callerStack)
159
- ? callerStack.filter((line): line is string => typeof line === "string")
160
- : typeof callerStack === "string"
161
- ? callerStack.split("\n")
162
- : [];
163
-
164
- return (
165
- stackLines
166
- .find(
167
- line =>
168
- line.includes("CCTVManagerVideoOverlay") ||
169
- line.includes("reconnectOnFocus"),
170
- )
171
- ?.trim() ?? null
172
- );
173
- };
174
-
175
- const countBy = <T extends string>(
176
- target: Record<T, number>,
177
- key: T | null,
178
- ): void => {
179
- if (!key) return;
180
- target[key] = (target[key] ?? 0) + 1;
181
- };
182
-
183
- const filterCctvDebugEvents = (
184
- events: readonly CctvDebugEvent[],
185
- keywords: readonly string[] = DEBUG_DEFAULT_FILTER_KEYWORDS,
186
- ): CctvDebugEvent[] =>
187
- events.filter(entry =>
188
- keywords.some(keyword => entry.event.includes(keyword)),
189
- );
190
-
191
- const summarizeCctvDebugEvents = (
192
- events: readonly CctvDebugEvent[],
193
- ): CctvDebugSummary => {
194
- const eventCounts: Record<string, number> = {};
195
- const connectionStateCounts: Record<string, number> = {};
196
- const reconnectReasons: Record<string, number> = {};
197
- const reconnectCallers: Record<string, number> = {};
198
- const reconnectStarts: CctvDebugReconnectStartSummary[] = [];
199
-
200
- for (const entry of events) {
201
- countBy(eventCounts, entry.event);
202
-
203
- if (entry.event === "connection-state:change") {
204
- countBy(connectionStateCounts, getPayloadString(entry.payload, "state"));
205
- }
206
-
207
- if (entry.event !== "reconnect-stream:start") continue;
208
-
209
- const reconnectReason = getPayloadString(entry.payload, "reconnectReason");
210
- const caller = getReconnectCaller(entry.payload);
211
-
212
- countBy(reconnectReasons, reconnectReason ?? "none");
213
- countBy(reconnectCallers, caller ?? "unknown");
214
- reconnectStarts.push({
215
- at: entry.at,
216
- caller,
217
- camId: getPayloadString(entry.payload, "camId"),
218
- connectionState: getPayloadString(entry.payload, "connectionState"),
219
- isPostConnectedReconnectReady: getPayloadBoolean(
220
- entry.payload,
221
- "isPostConnectedReconnectReady",
222
- ),
223
- reconnectReason,
224
- });
225
- }
226
-
227
- return {
228
- connectionStateCounts,
229
- eventCounts,
230
- first: events[0]?.at ?? null,
231
- generatedAt: new Date().toISOString(),
232
- last: events[events.length - 1]?.at ?? null,
233
- reconnectCallers,
234
- reconnectReasons,
235
- reconnectStarts,
236
- total: events.length,
237
- };
238
- };
239
-
240
- const decodeBase64UrlJson = (value: string): Record<string, unknown> | null => {
241
- try {
242
- const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
243
- const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
244
- const parsed = JSON.parse(atob(padded));
245
-
246
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
247
- ? (parsed as Record<string, unknown>)
248
- : null;
249
- } catch {
250
- return null;
251
- }
252
- };
253
-
254
- const getJwtStringClaim = (
255
- claims: Record<string, unknown> | null,
256
- key: string,
257
- ): string | null => {
258
- const value = claims?.[key];
259
- return typeof value === "string" ? value : null;
260
- };
261
-
262
- const getJwtExpiresAt = (
263
- claims: Record<string, unknown> | null,
264
- ): { expiresAt: string | null; expiresInSec: number | null } => {
265
- const exp = claims?.exp;
266
- if (typeof exp !== "number" || !Number.isFinite(exp)) {
267
- return { expiresAt: null, expiresInSec: null };
268
- }
269
-
270
- const expiresAtMs = exp * 1000;
271
- return {
272
- expiresAt: new Date(expiresAtMs).toISOString(),
273
- expiresInSec: Math.floor((expiresAtMs - Date.now()) / 1000),
274
- };
275
- };
276
-
277
- const stringifyCctvDebugEvent = (entry: CctvDebugEvent): string => {
278
- try {
279
- return JSON.stringify(entry);
280
- } catch {
281
- return JSON.stringify({
282
- at: entry.at,
283
- event: entry.event,
284
- level: entry.level,
285
- seq: entry.seq,
286
- source: entry.source,
287
- });
288
- }
289
- };
290
-
291
- /**
292
- * identity/stream key를 원문 대신 추적 가능한 hash label로 바꾼다.
293
- */
294
- export const getCctvDebugKeyLabel = (
295
- value: string | null | undefined,
296
- ): string | null => {
297
- if (!value) return null;
298
-
299
- let hash = 0;
300
- for (let i = 0; i < value.length; i += 1) {
301
- hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
302
- }
303
-
304
- return `hash:${hash.toString(36)}|len:${value.length}`;
305
- };
306
-
307
- /**
308
- * endpoint는 query string을 제거한 origin/path만 기록한다.
309
- */
310
- export const getCctvDebugUrlLabel = (
311
- value: string | null | undefined,
312
- ): string | null => {
313
- if (!value) return null;
314
-
315
- try {
316
- const url = new URL(value);
317
- return `${url.origin}${url.pathname}`;
318
- } catch {
319
- return value.split("?")[0] ?? null;
320
- }
321
- };
322
-
323
- /**
324
- * JWT 원문 대신 헤더/만료시각과 hash label만 기록한다.
325
- * @desc
326
- * token signature mismatch를 추적하기 위한 메타 정보이며, Bearer token 원문과 payload claim은
327
- * debug log에 남기지 않는다.
328
- */
329
- export const getCctvDebugJwtMetadata = (
330
- token: string | null | undefined,
331
- ): CctvDebugJwtMetadata | null => {
332
- if (!token) return null;
333
-
334
- const [headerPart, payloadPart] = token.split(".");
335
- const header = headerPart ? decodeBase64UrlJson(headerPart) : null;
336
- const payload = payloadPart ? decodeBase64UrlJson(payloadPart) : null;
337
- const { expiresAt, expiresInSec } = getJwtExpiresAt(payload);
338
-
339
- return {
340
- expiresAt,
341
- expiresInSec,
342
- headerAlg: getJwtStringClaim(header, "alg"),
343
- headerKid: getJwtStringClaim(header, "kid"),
344
- tokenLabel: getCctvDebugKeyLabel(token),
345
- tokenPartCount: token.split(".").length,
346
- };
347
- };
348
-
349
- /**
350
- * CCTV debug logging 활성화 여부를 확인한다.
351
- * @desc
352
- * production default는 off, localhost default는 ring buffer on이다.
353
- * console 출력은 별도 console flag를 켠 경우에만 활성화한다.
354
- */
355
- export const isCctvDebugEnabled = (): boolean => {
356
- if (!isBrowser()) return false;
357
-
358
- const queryOverride = getDebugQueryOverride(DEBUG_QUERY_KEYS);
359
- if (queryOverride !== null) return queryOverride;
360
-
361
- const consoleOverride = getDebugConsoleOverride();
362
- if (consoleOverride === true) return true;
363
-
364
- const storageOverride = getDebugStorageOverride(DEBUG_STORAGE_KEYS);
365
- if (storageOverride !== null) return storageOverride;
366
-
367
- return isLocalhostDebugDefaultEnabled();
368
- };
369
-
370
- const isCctvDebugConsoleEnabled = (): boolean => {
371
- if (!isBrowser()) return false;
372
-
373
- return getDebugConsoleOverride() === true;
374
- };
375
-
376
- /**
377
- * reconnect 호출 주체를 좁히기 위한 stack trace 일부를 만든다.
378
- */
379
- export const getCctvDebugStackTrace = (): string[] | null => {
380
- if (!isCctvDebugEnabled()) return null;
381
-
382
- const stack = new Error().stack;
383
- if (!stack) return null;
384
-
385
- return stack
386
- .split("\n")
387
- .slice(2, 8)
388
- .map(line => line.trim());
389
- };
390
-
391
- const getCctvDebugBuffer = (): CctvDebugBuffer | null => {
392
- if (!isCctvDebugEnabled()) return null;
393
-
394
- const existingBuffer = window.__UDS_CCTV_DEBUG__;
395
- if (existingBuffer) return existingBuffer;
396
-
397
- const buffer: CctvDebugBuffer = {
398
- enabled: true,
399
- events: [],
400
- limit: DEBUG_BUFFER_LIMIT,
401
- sequence: 0,
402
- clear: () => {
403
- buffer.events.splice(0, buffer.events.length);
404
- buffer.sequence = 0;
405
- },
406
- dump: () => [...buffer.events],
407
- filter: keywords => filterCctvDebugEvents(buffer.events, keywords),
408
- summary: () => summarizeCctvDebugEvents(buffer.events),
409
- };
410
-
411
- window.__UDS_CCTV_DEBUG__ = buffer;
412
- return buffer;
413
- };
414
-
415
- /**
416
- * CCTV runtime 진단 이벤트를 ring buffer와 console에 남긴다.
417
- * @desc
418
- * 토큰 값은 payload에 넣지 않는다. 이 logger는 debug flag가 켜진 경우에만 동작한다.
419
- */
420
- export const logCctvDebugEvent = ({
421
- event,
422
- level = "debug",
423
- payload,
424
- source,
425
- }: {
426
- event: string;
427
- level?: CctvDebugEventLevel;
428
- payload?: Record<string, unknown>;
429
- source: string;
430
- }): void => {
431
- const buffer = getCctvDebugBuffer();
432
- if (!buffer) return;
433
-
434
- const entry: CctvDebugEvent = {
435
- at: new Date().toISOString(),
436
- event,
437
- level,
438
- payload,
439
- seq: buffer.sequence + 1,
440
- source,
441
- };
442
-
443
- buffer.sequence = entry.seq;
444
- buffer.events.push(entry);
445
- if (buffer.events.length > buffer.limit) {
446
- buffer.events.splice(0, buffer.events.length - buffer.limit);
447
- }
448
-
449
- if (!isCctvDebugConsoleEnabled()) return;
450
-
451
- const logger =
452
- level === "error"
453
- ? console.error
454
- : level === "warn"
455
- ? console.warn
456
- : level === "info"
457
- ? console.info
458
- : console.debug;
459
-
460
- logger.call(console, `[UDS:CCTV] ${stringifyCctvDebugEvent(entry)}`);
461
- };