@uniai-fe/uds-templates 0.6.14 → 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 +1 -1
- package/package.json +1 -1
- package/src/cctv/hooks/streamRegistry.ts +0 -151
- package/src/cctv/hooks/useRtcStream.ts +13 -255
- package/src/cctv/styles/video.scss +1 -1
- package/src/cctv/types/hook.ts +8 -3
- package/src/cctv/types/video-state.ts +10 -4
- package/src/cctv/utils/video-state.ts +3 -4
- package/src/cctv/utils/debug.ts +0 -461
package/dist/styles.css
CHANGED
package/package.json
CHANGED
|
@@ -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
|
|
|
@@ -138,7 +133,7 @@ const canUseTokenForNewConnection = ({
|
|
|
138
133
|
* @desc
|
|
139
134
|
* return {
|
|
140
135
|
* videoRef, // WebRTC MediaStream을 연결할 video 요소 ref
|
|
141
|
-
* connectionState, // RTCPeerConnectionState 상태값
|
|
136
|
+
* connectionState, // UI 표시 기준 RTCPeerConnectionState 상태값
|
|
142
137
|
* streamError, // 스트림 시도 중 발생한 오류 메시지
|
|
143
138
|
* isStreaming, // UI 표시 기준 스트림 연결 절차 진행 여부
|
|
144
139
|
* isTokenLoading, // UI 표시 기준 토큰 발급 요청 진행 여부
|
|
@@ -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
|
-
}, [
|
|
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,
|
|
@@ -328,6 +278,13 @@ export function useCctvRtcStream({
|
|
|
328
278
|
streamIdentityKey,
|
|
329
279
|
]);
|
|
330
280
|
|
|
281
|
+
const isPostConnectedRecoverableState =
|
|
282
|
+
hasConnected &&
|
|
283
|
+
Boolean(streamIdentityKey) &&
|
|
284
|
+
!isTokenError &&
|
|
285
|
+
!streamError &&
|
|
286
|
+
DISPLAY_CONNECTED_DURING_RECOVERY_STATES.has(connectionState);
|
|
287
|
+
|
|
331
288
|
const isPostConnectedReplacementLoading =
|
|
332
289
|
hasConnected &&
|
|
333
290
|
Boolean(streamIdentityKey) &&
|
|
@@ -336,13 +293,10 @@ export function useCctvRtcStream({
|
|
|
336
293
|
(isTokenLoading || isStreaming);
|
|
337
294
|
|
|
338
295
|
const shouldPreserveConnectedDisplay =
|
|
339
|
-
isPostConnectedReplacementLoading ||
|
|
340
|
-
(hasConnected &&
|
|
341
|
-
!isPostConnectedReconnectReady &&
|
|
342
|
-
DISPLAY_CONNECTED_DURING_RECOVERY_STATES.has(connectionState));
|
|
296
|
+
isPostConnectedReplacementLoading || isPostConnectedRecoverableState;
|
|
343
297
|
|
|
344
298
|
// 반환 state는 CamList/Viewer/overlay의 live/error/message 계산에 직접 쓰인다.
|
|
345
|
-
// post-connected recovery/replacement 중에는 기존
|
|
299
|
+
// post-connected recovery/replacement 중에는 canReconnect만 열고, 기존 화면은 유지해 UI reset 파동을 막는다.
|
|
346
300
|
const displayConnectionState = shouldPreserveConnectedDisplay
|
|
347
301
|
? "connected"
|
|
348
302
|
: connectionState;
|
|
@@ -353,36 +307,6 @@ export function useCctvRtcStream({
|
|
|
353
307
|
? false
|
|
354
308
|
: isTokenLoading;
|
|
355
309
|
|
|
356
|
-
const skippedDisconnectedDebugKeyRef = useRef<string | null>(null);
|
|
357
|
-
|
|
358
|
-
useEffect(() => {
|
|
359
|
-
if (
|
|
360
|
-
connectionState !== "disconnected" ||
|
|
361
|
-
!hasConnected ||
|
|
362
|
-
!streamIdentityKey
|
|
363
|
-
) {
|
|
364
|
-
if (connectionState !== "disconnected") {
|
|
365
|
-
skippedDisconnectedDebugKeyRef.current = null;
|
|
366
|
-
}
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const skipKey = `${streamIdentityKey}|${activeStreamKeyRef.current ?? ""}`;
|
|
371
|
-
if (skippedDisconnectedDebugKeyRef.current === skipKey) return;
|
|
372
|
-
|
|
373
|
-
skippedDisconnectedDebugKeyRef.current = skipKey;
|
|
374
|
-
logCctvDebugEvent({
|
|
375
|
-
event: "reconnect-gate:skip-disconnected",
|
|
376
|
-
payload: {
|
|
377
|
-
...debugBasePayload,
|
|
378
|
-
connectionState,
|
|
379
|
-
reason: "disconnected-observed-only",
|
|
380
|
-
streamKey: getCctvDebugKeyLabel(activeStreamKeyRef.current),
|
|
381
|
-
},
|
|
382
|
-
source: "useRtcStream",
|
|
383
|
-
});
|
|
384
|
-
}, [connectionState, debugBasePayload, hasConnected, streamIdentityKey]);
|
|
385
|
-
|
|
386
310
|
const hasReusableRegistryStream = useMemo(() => {
|
|
387
311
|
if (!streamKeyCandidate) return false;
|
|
388
312
|
|
|
@@ -432,19 +356,9 @@ export function useCctvRtcStream({
|
|
|
432
356
|
}
|
|
433
357
|
|
|
434
358
|
staleTokenRefreshKeyRef.current = streamKeyCandidate;
|
|
435
|
-
logCctvDebugEvent({
|
|
436
|
-
event: "token:stale-refetch",
|
|
437
|
-
payload: {
|
|
438
|
-
...debugBasePayload,
|
|
439
|
-
dataUpdatedAt: tokenQuery.dataUpdatedAt,
|
|
440
|
-
streamKey: getCctvDebugKeyLabel(streamKeyCandidate),
|
|
441
|
-
},
|
|
442
|
-
source: "useRtcStream",
|
|
443
|
-
});
|
|
444
359
|
void refetchRtcToken();
|
|
445
360
|
}, [
|
|
446
361
|
canUseTokenForStream,
|
|
447
|
-
debugBasePayload,
|
|
448
362
|
refetchRtcToken,
|
|
449
363
|
streamKeyCandidate,
|
|
450
364
|
tokenQuery.data?.token,
|
|
@@ -459,15 +373,6 @@ export function useCctvRtcStream({
|
|
|
459
373
|
|
|
460
374
|
// 토큰/카메라 에러로 전환되면 직전 MediaStream이 화면에 남지 않도록 비운다.
|
|
461
375
|
if (tokenQuery.isError || !cam?.cam_online) {
|
|
462
|
-
logCctvDebugEvent({
|
|
463
|
-
event: "stream-effect:cleanup-token-or-offline",
|
|
464
|
-
payload: {
|
|
465
|
-
...debugBasePayload,
|
|
466
|
-
camOnline: cam?.cam_online,
|
|
467
|
-
isTokenError: tokenQuery.isError,
|
|
468
|
-
},
|
|
469
|
-
source: "useRtcStream",
|
|
470
|
-
});
|
|
471
376
|
if (streamIdentityKey) {
|
|
472
377
|
streamRegistry.closeByIdentity(streamIdentityKey);
|
|
473
378
|
}
|
|
@@ -493,42 +398,12 @@ export function useCctvRtcStream({
|
|
|
493
398
|
if (!previousStreamKey || didClosePreviousStream) return;
|
|
494
399
|
|
|
495
400
|
didClosePreviousStream = true;
|
|
496
|
-
logCctvDebugEvent({
|
|
497
|
-
event: "stream-effect:close-previous-after-track",
|
|
498
|
-
payload: {
|
|
499
|
-
...debugBasePayload,
|
|
500
|
-
previousStreamKey: getCctvDebugKeyLabel(previousStreamKey),
|
|
501
|
-
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
502
|
-
},
|
|
503
|
-
source: "useRtcStream",
|
|
504
|
-
});
|
|
505
401
|
streamRegistry.closeByIdentity(streamIdentityKey, streamKey);
|
|
506
402
|
};
|
|
507
403
|
|
|
508
|
-
if (previousStreamKey) {
|
|
509
|
-
logCctvDebugEvent({
|
|
510
|
-
event: "stream-effect:prepare-replacement",
|
|
511
|
-
payload: {
|
|
512
|
-
...debugBasePayload,
|
|
513
|
-
previousStreamKey: getCctvDebugKeyLabel(previousStreamKey),
|
|
514
|
-
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
515
|
-
},
|
|
516
|
-
source: "useRtcStream",
|
|
517
|
-
});
|
|
518
|
-
}
|
|
519
|
-
|
|
520
404
|
activeStreamKeyRef.current = streamKey;
|
|
521
405
|
activeStreamIdentityKeyRef.current = streamIdentityKey;
|
|
522
406
|
|
|
523
|
-
logCctvDebugEvent({
|
|
524
|
-
event: "stream-effect:start",
|
|
525
|
-
payload: {
|
|
526
|
-
...debugBasePayload,
|
|
527
|
-
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
528
|
-
},
|
|
529
|
-
source: "useRtcStream",
|
|
530
|
-
});
|
|
531
|
-
|
|
532
407
|
streamRegistry.start({
|
|
533
408
|
streamKey,
|
|
534
409
|
identityKey: streamIdentityKey,
|
|
@@ -543,14 +418,6 @@ export function useCctvRtcStream({
|
|
|
543
418
|
setStreamError(snapshot.streamError);
|
|
544
419
|
setStreaming(snapshot.isStreaming);
|
|
545
420
|
if (snapshot.connectionState === "connected") {
|
|
546
|
-
logCctvDebugEvent({
|
|
547
|
-
event: "stream-effect:has-connected",
|
|
548
|
-
payload: {
|
|
549
|
-
...debugBasePayload,
|
|
550
|
-
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
551
|
-
},
|
|
552
|
-
source: "useRtcStream",
|
|
553
|
-
});
|
|
554
421
|
setHasConnected(true);
|
|
555
422
|
}
|
|
556
423
|
if (snapshot.stream) {
|
|
@@ -575,19 +442,10 @@ export function useCctvRtcStream({
|
|
|
575
442
|
|
|
576
443
|
// effect cleanup: current video attach만 해제하고 registry stream은 유지한다.
|
|
577
444
|
return () => {
|
|
578
|
-
logCctvDebugEvent({
|
|
579
|
-
event: "stream-effect:detach-video",
|
|
580
|
-
payload: {
|
|
581
|
-
...debugBasePayload,
|
|
582
|
-
streamKey: getCctvDebugKeyLabel(streamKey),
|
|
583
|
-
},
|
|
584
|
-
source: "useRtcStream",
|
|
585
|
-
});
|
|
586
445
|
unsubscribe();
|
|
587
446
|
detachVideo();
|
|
588
447
|
};
|
|
589
448
|
}, [
|
|
590
|
-
debugBasePayload,
|
|
591
449
|
endpoint,
|
|
592
450
|
streamIdentityKey,
|
|
593
451
|
streamKey,
|
|
@@ -597,52 +455,8 @@ export function useCctvRtcStream({
|
|
|
597
455
|
cam?.cam_online,
|
|
598
456
|
]);
|
|
599
457
|
|
|
600
|
-
const debugReconnectStateRef = useRef({
|
|
601
|
-
connectionState,
|
|
602
|
-
debugBasePayload,
|
|
603
|
-
isPostConnectedReconnectReady,
|
|
604
|
-
reconnectReason,
|
|
605
|
-
streamError,
|
|
606
|
-
});
|
|
607
|
-
debugReconnectStateRef.current = {
|
|
608
|
-
connectionState,
|
|
609
|
-
debugBasePayload,
|
|
610
|
-
isPostConnectedReconnectReady,
|
|
611
|
-
reconnectReason,
|
|
612
|
-
streamError,
|
|
613
|
-
};
|
|
614
|
-
|
|
615
458
|
const reconnectStream = useCallback<CctvRtcReconnectTrigger>(
|
|
616
|
-
|
|
617
|
-
const debugState = debugReconnectStateRef.current;
|
|
618
|
-
logCctvDebugEvent({
|
|
619
|
-
event: "reconnect-stream:start",
|
|
620
|
-
payload: {
|
|
621
|
-
...debugState.debugBasePayload,
|
|
622
|
-
connectionState: debugState.connectionState,
|
|
623
|
-
callerStack: getCctvDebugStackTrace(),
|
|
624
|
-
isPostConnectedReconnectReady:
|
|
625
|
-
debugState.isPostConnectedReconnectReady,
|
|
626
|
-
reconnectReason: debugState.reconnectReason,
|
|
627
|
-
streamError: debugState.streamError,
|
|
628
|
-
},
|
|
629
|
-
source: "useRtcStream",
|
|
630
|
-
});
|
|
631
|
-
const result = await refetchRtcToken(options);
|
|
632
|
-
const resultDebugState = debugReconnectStateRef.current;
|
|
633
|
-
logCctvDebugEvent({
|
|
634
|
-
event: "reconnect-stream:token-result",
|
|
635
|
-
level: result.isSuccess ? "info" : "warn",
|
|
636
|
-
payload: {
|
|
637
|
-
...resultDebugState.debugBasePayload,
|
|
638
|
-
isError: result.isError,
|
|
639
|
-
isSuccess: result.isSuccess,
|
|
640
|
-
status: result.status,
|
|
641
|
-
},
|
|
642
|
-
source: "useRtcStream",
|
|
643
|
-
});
|
|
644
|
-
return result;
|
|
645
|
-
},
|
|
459
|
+
options => refetchRtcToken(options),
|
|
646
460
|
[refetchRtcToken],
|
|
647
461
|
);
|
|
648
462
|
|
|
@@ -678,16 +492,6 @@ export function useCctvRtcStream({
|
|
|
678
492
|
return;
|
|
679
493
|
|
|
680
494
|
lastAutoReconnectAtRef.current = now;
|
|
681
|
-
logCctvDebugEvent({
|
|
682
|
-
event: "focus:auto-reconnect",
|
|
683
|
-
payload: {
|
|
684
|
-
...debugBasePayload,
|
|
685
|
-
canReconnect,
|
|
686
|
-
connectionState,
|
|
687
|
-
reconnectReason,
|
|
688
|
-
},
|
|
689
|
-
source: "useRtcStream",
|
|
690
|
-
});
|
|
691
495
|
void reconnectStream();
|
|
692
496
|
};
|
|
693
497
|
|
|
@@ -698,13 +502,7 @@ export function useCctvRtcStream({
|
|
|
698
502
|
window.removeEventListener("focus", reconnectOnFocus);
|
|
699
503
|
document.removeEventListener("visibilitychange", reconnectOnFocus);
|
|
700
504
|
};
|
|
701
|
-
}, [
|
|
702
|
-
canReconnect,
|
|
703
|
-
connectionState,
|
|
704
|
-
debugBasePayload,
|
|
705
|
-
reconnectReason,
|
|
706
|
-
reconnectStream,
|
|
707
|
-
]);
|
|
505
|
+
}, [canReconnect, reconnectStream]);
|
|
708
506
|
|
|
709
507
|
const liveState = useMemo(
|
|
710
508
|
() =>
|
|
@@ -728,46 +526,6 @@ export function useCctvRtcStream({
|
|
|
728
526
|
],
|
|
729
527
|
);
|
|
730
528
|
|
|
731
|
-
useEffect(() => {
|
|
732
|
-
logCctvDebugEvent({
|
|
733
|
-
event: "hook-state:update",
|
|
734
|
-
payload: {
|
|
735
|
-
...debugBasePayload,
|
|
736
|
-
canReconnect,
|
|
737
|
-
connectionState,
|
|
738
|
-
displayConnectionState,
|
|
739
|
-
hasConnected,
|
|
740
|
-
isPostConnectedReplacementLoading,
|
|
741
|
-
isPostConnectedReconnectReady,
|
|
742
|
-
displayIsStreaming,
|
|
743
|
-
displayIsTokenLoading,
|
|
744
|
-
isStreaming,
|
|
745
|
-
isTokenError,
|
|
746
|
-
isTokenLoading,
|
|
747
|
-
liveState,
|
|
748
|
-
reconnectReason,
|
|
749
|
-
streamError,
|
|
750
|
-
},
|
|
751
|
-
source: "useRtcStream",
|
|
752
|
-
});
|
|
753
|
-
}, [
|
|
754
|
-
canReconnect,
|
|
755
|
-
connectionState,
|
|
756
|
-
debugBasePayload,
|
|
757
|
-
displayConnectionState,
|
|
758
|
-
displayIsStreaming,
|
|
759
|
-
displayIsTokenLoading,
|
|
760
|
-
hasConnected,
|
|
761
|
-
isPostConnectedReplacementLoading,
|
|
762
|
-
isPostConnectedReconnectReady,
|
|
763
|
-
isStreaming,
|
|
764
|
-
isTokenError,
|
|
765
|
-
isTokenLoading,
|
|
766
|
-
liveState,
|
|
767
|
-
reconnectReason,
|
|
768
|
-
streamError,
|
|
769
|
-
]);
|
|
770
|
-
|
|
771
529
|
useEffect(() => {
|
|
772
530
|
const camId = cam?.cam_id;
|
|
773
531
|
if (!camId) return;
|
package/src/cctv/types/hook.ts
CHANGED
|
@@ -59,7 +59,7 @@ export interface UseCctvRtcStreamError {
|
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
61
|
* CCTV; useCctvRtcStream 연결 상태
|
|
62
|
-
* @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
|
|
62
|
+
* @property {RTCPeerConnectionState} connectionState UI 표시 기준 WebRTC 연결 상태
|
|
63
63
|
* @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
|
|
64
64
|
* @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
|
|
65
65
|
* @property {boolean} isTokenError 토큰 발급 실패 여부
|
|
@@ -67,7 +67,11 @@ export interface UseCctvRtcStreamError {
|
|
|
67
67
|
*/
|
|
68
68
|
export interface UseCctvRtcStreamState extends UseCctvRtcStreamError {
|
|
69
69
|
/**
|
|
70
|
-
* WebRTC 연결 상태
|
|
70
|
+
* UI 표시 기준 WebRTC 연결 상태
|
|
71
|
+
* @desc
|
|
72
|
+
* 최초 연결 전에는 실제 PeerConnection 상태를 나타낸다. 이미 한 번 연결된 stream이
|
|
73
|
+
* recoverable `disconnected`/`failed` 상태에 들어가면 기존 화면과 live count 유지를 위해
|
|
74
|
+
* `connected`로 smoothing될 수 있다.
|
|
71
75
|
*/
|
|
72
76
|
connectionState: RTCPeerConnectionState;
|
|
73
77
|
/**
|
|
@@ -89,7 +93,7 @@ export interface UseCctvRtcStreamState extends UseCctvRtcStreamError {
|
|
|
89
93
|
/**
|
|
90
94
|
* CCTV; useCctvRtcStream return
|
|
91
95
|
* @property {React.RefObject<HTMLVideoElement>} videoRef <video /> ref
|
|
92
|
-
* @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
|
|
96
|
+
* @property {RTCPeerConnectionState} connectionState UI 표시 기준 WebRTC 연결 상태
|
|
93
97
|
* @property {string | null} streamError 스트림 오류 메시지
|
|
94
98
|
* @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
|
|
95
99
|
* @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
|
|
@@ -110,6 +114,7 @@ export interface UseCctvRtcStreamReturn extends UseCctvRtcStreamState {
|
|
|
110
114
|
* 서비스 overlay가 reconnectStream()을 호출해도 되는 시점에 true가 된다.
|
|
111
115
|
* post-connected `disconnected`는 자동 재연결 사유가 아니며,
|
|
112
116
|
* token/stream 오류 또는 `failed`처럼 명확한 복구 사유만 true 전환 후보가 된다.
|
|
117
|
+
* recoverable `failed`에서는 화면이 live로 유지되는 동안에도 true가 될 수 있다.
|
|
113
118
|
*/
|
|
114
119
|
canReconnect: boolean;
|
|
115
120
|
/**
|
|
@@ -6,12 +6,13 @@ import type { UseCctvRtcStreamError, UseCctvRtcStreamState } from "./hook";
|
|
|
6
6
|
* @property {CctvCompanyCameraData} [cam] 카메라 데이터
|
|
7
7
|
* @property {boolean} [hasCamProp] 직접 cam prop을 받은 경우 true
|
|
8
8
|
* @property {boolean} [isFetching] 서버에서 cam 리스트를 가져오는 중인지 여부
|
|
9
|
-
* @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
|
|
9
|
+
* @property {RTCPeerConnectionState} connectionState UI 표시 기준 WebRTC 연결 상태
|
|
10
10
|
* @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
|
|
11
11
|
* @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
|
|
12
12
|
* @property {boolean} isTokenError 토큰 발급 실패 여부
|
|
13
13
|
* @property {string | null} streamError 스트림 오류 메시지
|
|
14
|
-
* @property {boolean} [canReconnect] UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지
|
|
14
|
+
* @property {boolean} [canReconnect] UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부.
|
|
15
|
+
* 오류 스타일 판정에는 직접 사용하지 않는다.
|
|
15
16
|
*/
|
|
16
17
|
export interface CctvVideoOverlayMessageParams extends UseCctvRtcStreamState {
|
|
17
18
|
/**
|
|
@@ -28,6 +29,8 @@ export interface CctvVideoOverlayMessageParams extends UseCctvRtcStreamState {
|
|
|
28
29
|
isFetching?: boolean;
|
|
29
30
|
/**
|
|
30
31
|
* UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
32
|
+
* @desc
|
|
33
|
+
* 재연결 trigger gate이며, 기본 error style 판정에는 직접 사용하지 않는다.
|
|
31
34
|
*/
|
|
32
35
|
canReconnect?: boolean;
|
|
33
36
|
}
|
|
@@ -35,7 +38,7 @@ export interface CctvVideoOverlayMessageParams extends UseCctvRtcStreamState {
|
|
|
35
38
|
/**
|
|
36
39
|
* CCTV; getIsLive() params
|
|
37
40
|
* @property {CctvCompanyCameraData} [cam] 카메라 데이터
|
|
38
|
-
* @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
|
|
41
|
+
* @property {RTCPeerConnectionState} connectionState UI 표시 기준 WebRTC 연결 상태
|
|
39
42
|
* @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
|
|
40
43
|
* @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
|
|
41
44
|
* @property {boolean} isTokenError 토큰 발급 실패 여부
|
|
@@ -53,7 +56,8 @@ export interface CctvVideoLiveParams extends UseCctvRtcStreamState {
|
|
|
53
56
|
* @property {CctvCompanyCameraData} [cam] 카메라 데이터
|
|
54
57
|
* @property {boolean} isTokenError 토큰 발급 실패 여부
|
|
55
58
|
* @property {string | null} streamError 스트림 오류 메시지
|
|
56
|
-
* @property {boolean} [canReconnect] UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지
|
|
59
|
+
* @property {boolean} [canReconnect] UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부.
|
|
60
|
+
* 오류 스타일 판정에는 직접 사용하지 않는다.
|
|
57
61
|
*/
|
|
58
62
|
export interface CctvVideoErrorParams extends UseCctvRtcStreamError {
|
|
59
63
|
/**
|
|
@@ -62,6 +66,8 @@ export interface CctvVideoErrorParams extends UseCctvRtcStreamError {
|
|
|
62
66
|
cam?: CctvCompanyCameraData;
|
|
63
67
|
/**
|
|
64
68
|
* UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
|
|
69
|
+
* @desc
|
|
70
|
+
* 재연결 trigger gate이며, 기본 error style 판정에는 직접 사용하지 않는다.
|
|
65
71
|
*/
|
|
66
72
|
canReconnect?: boolean;
|
|
67
73
|
}
|
|
@@ -31,7 +31,7 @@ const RTC_SESSION_ENDED_RECONNECT_STATES = new Set<RTCPeerConnectionState>([
|
|
|
31
31
|
* @property {CctvCompanyCameraData} [cam] 카메라 데이터
|
|
32
32
|
* @property {boolean} [hasCamProp] 직접 cam prop을 받은 경우 true
|
|
33
33
|
* @property {boolean} [isFetching] 서버에서 cam 리스트를 가져오는 중인지 여부
|
|
34
|
-
* @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
|
|
34
|
+
* @property {RTCPeerConnectionState} connectionState UI 표시 기준 WebRTC 연결 상태
|
|
35
35
|
* @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
|
|
36
36
|
* @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
|
|
37
37
|
* @property {boolean} isTokenError 토큰 발급 실패 여부
|
|
@@ -72,7 +72,7 @@ export function getOverlayMessage({
|
|
|
72
72
|
* CCTV; 스트리밍 라이브 상태 판단
|
|
73
73
|
* @param {CctvVideoLiveParams} params 상태 파라미터
|
|
74
74
|
* @property {CctvCompanyCameraData} [cam] 카메라 데이터
|
|
75
|
-
* @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
|
|
75
|
+
* @property {RTCPeerConnectionState} connectionState UI 표시 기준 WebRTC 연결 상태
|
|
76
76
|
* @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
|
|
77
77
|
* @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
|
|
78
78
|
* @property {boolean} isTokenError 토큰 발급 실패 여부
|
|
@@ -102,17 +102,16 @@ export function getIsLive({
|
|
|
102
102
|
* @property {CctvCompanyCameraData} [cam] 카메라 데이터
|
|
103
103
|
* @property {boolean} isTokenError 토큰 발급 실패 여부
|
|
104
104
|
* @property {string | null} streamError 스트림 오류 메시지
|
|
105
|
+
* @property {boolean} [canReconnect] 재연결 trigger 가능 여부. 오류 표시 여부에는 직접 사용하지 않는다.
|
|
105
106
|
* @returns {boolean} 에러 상태 여부 반환
|
|
106
107
|
*/
|
|
107
108
|
export function getIsError({
|
|
108
109
|
cam,
|
|
109
|
-
canReconnect,
|
|
110
110
|
isTokenError,
|
|
111
111
|
streamError,
|
|
112
112
|
}: CctvVideoErrorParams): boolean {
|
|
113
113
|
if (!cam) return true;
|
|
114
114
|
if (!cam.cam_online) return true;
|
|
115
|
-
if (canReconnect) return true;
|
|
116
115
|
if (isTokenError) return true;
|
|
117
116
|
if (streamError) return true;
|
|
118
117
|
return false;
|
package/src/cctv/utils/debug.ts
DELETED
|
@@ -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
|
-
};
|