@uniai-fe/uds-templates 0.10.2 → 0.10.4

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.10.2",
3
+ "version": "0.10.4",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -56,27 +56,27 @@
56
56
  },
57
57
  "devDependencies": {
58
58
  "@svgr/webpack": "^8.1.0",
59
- "@tanstack/react-query": "^5.101.1",
60
- "@types/node": "^24.12.3",
59
+ "@tanstack/react-query": "^5.101.2",
60
+ "@types/node": "^24.13.2",
61
61
  "@types/react": "^19.2.17",
62
62
  "@types/react-dom": "^19.2.3",
63
- "eslint": "^9.39.2",
64
- "jotai": "^2.20.1",
63
+ "eslint": "^9.39.4",
64
+ "jotai": "^2.20.2",
65
65
  "next": "16.2.10",
66
- "prettier": "^3.8.4",
67
- "react-hook-form": "^7.80.0",
66
+ "prettier": "^3.8.5",
67
+ "react-hook-form": "^7.81.0",
68
68
  "sass": "^1.101.0",
69
69
  "typescript": "6.0.3",
70
70
  "@uniai-fe/next-devkit": "0.4.0",
71
71
  "@uniai-fe/eslint-config": "0.4.1",
72
- "@uniai-fe/util-api": "0.2.1",
73
72
  "@uniai-fe/tsconfig": "0.2.0",
74
73
  "@uniai-fe/react-hooks": "0.3.0",
74
+ "@uniai-fe/util-api": "0.2.1",
75
+ "@uniai-fe/uds-primitives": "0.10.0",
75
76
  "@uniai-fe/util-functions": "0.4.3",
76
77
  "@uniai-fe/util-jotai": "0.3.0",
78
+ "@uniai-fe/util-rtc": "0.2.1",
77
79
  "@uniai-fe/uds-foundation": "0.5.0",
78
- "@uniai-fe/uds-primitives": "0.10.0",
79
- "@uniai-fe/util-rtc": "0.2.0",
80
80
  "@uniai-fe/util-next": "0.5.0"
81
81
  },
82
82
  "scripts": {
@@ -2,30 +2,119 @@
2
2
 
3
3
  import { startWhepStream, type WhepStreamHandle } from "@uniai-fe/util-rtc";
4
4
 
5
+ /**
6
+ * CCTV Stream Registry Types; 외부 구독 snapshot
7
+ * @property {RTCPeerConnectionState} connectionState PeerConnection 연결 상태
8
+ * @property {boolean} isStreaming WHEP 시작 promise 진행 여부
9
+ * @property {MediaStream | null} stream 현재 수신 중인 MediaStream
10
+ * @property {string | null} streamError 마지막 WHEP 시작 오류 메시지
11
+ * @desc 외부 subscriber에 노출하는 immutable RTC stream 상태 계약
12
+ */
5
13
  interface CctvRtcStreamSnapshot {
14
+ /**
15
+ * PeerConnection 연결 상태
16
+ */
6
17
  connectionState: RTCPeerConnectionState;
18
+ /**
19
+ * WHEP 시작 promise 진행 여부
20
+ */
7
21
  isStreaming: boolean;
22
+ /**
23
+ * 현재 수신 중인 MediaStream
24
+ */
8
25
  stream: MediaStream | null;
26
+ /**
27
+ * 마지막 WHEP 시작 오류 메시지
28
+ */
9
29
  streamError: string | null;
10
30
  }
11
31
 
32
+ /**
33
+ * CCTV Stream Registry Types; Provider scope stream entry
34
+ * @property {Set<HTMLVideoElement>} attachedVideos 같은 stream을 표시하는 video 집합
35
+ * @property {AbortController | null} controller 진행 중이거나 연결된 WHEP cleanup controller
36
+ * @property {WhepStreamHandle | null} handle 연결 완료 뒤 PeerConnection cleanup handle
37
+ * @property {string} identityKey camera와 endpoint 기준 stream family key
38
+ * @property {Set<(snapshot: CctvRtcStreamSnapshot) => void>} listeners snapshot 구독자 집합
39
+ * @property {MediaStream | null} pendingStream handle 반환 전 수신한 replacement stream
40
+ * @property {Map<HTMLVideoElement, MediaProvider | null>} previousVideoSources replacement 실패 시 복원할 video source
41
+ * @property {string} streamKey token generation까지 포함한 stream key
42
+ * @desc Provider scope에서 단일 stream lifecycle과 replacement rollback resource를 소유하는 내부 entry 계약
43
+ */
12
44
  interface CctvRtcStreamEntry extends CctvRtcStreamSnapshot {
45
+ /**
46
+ * 같은 stream을 표시하는 video element 집합
47
+ */
13
48
  attachedVideos: Set<HTMLVideoElement>;
49
+ /**
50
+ * 진행 중이거나 연결된 WHEP를 중단하는 controller
51
+ */
14
52
  controller: AbortController | null;
53
+ /**
54
+ * 연결 완료 뒤 PeerConnection을 닫는 handle
55
+ */
15
56
  handle: WhepStreamHandle | null;
57
+ /**
58
+ * camera와 endpoint 기준 stream family key
59
+ */
16
60
  identityKey: string;
61
+ /**
62
+ * entry snapshot 구독자 집합
63
+ */
17
64
  listeners: Set<(snapshot: CctvRtcStreamSnapshot) => void>;
65
+ /**
66
+ * handle 반환 전 수신한 replacement MediaStream
67
+ */
68
+ pendingStream: MediaStream | null;
69
+ /**
70
+ * replacement 실패 시 복원할 video별 기존 source
71
+ */
72
+ previousVideoSources: Map<HTMLVideoElement, MediaProvider | null>;
73
+ /**
74
+ * token generation까지 포함한 stream key
75
+ */
18
76
  streamKey: string;
19
77
  }
20
78
 
79
+ /**
80
+ * CCTV Stream Registry Types; RTC stream 시작 파라미터
81
+ * @property {string} endpoint token-specific WHEP 요청 endpoint
82
+ * @property {string} identityKey camera와 endpoint 기준 stream family key
83
+ * @property {string} streamKey token generation까지 포함한 stream key
84
+ * @property {string} token WHEP Bearer token
85
+ * @property {HTMLVideoElement} video 최초 stream attach 대상 video
86
+ * @desc registry가 token generation별 WHEP lifecycle을 시작하는 입력 계약
87
+ */
21
88
  interface CctvRtcStreamStartParams {
89
+ /**
90
+ * token-specific WHEP 요청 endpoint
91
+ */
22
92
  endpoint: string;
93
+ /**
94
+ * camera와 endpoint 기준 stream family key
95
+ */
23
96
  identityKey: string;
97
+ /**
98
+ * token generation까지 포함한 stream key
99
+ */
24
100
  streamKey: string;
101
+ /**
102
+ * WHEP Bearer token
103
+ */
25
104
  token: string;
105
+ /**
106
+ * 최초 stream attach 대상 video element
107
+ */
26
108
  video: HTMLVideoElement;
27
109
  }
28
110
 
111
+ /**
112
+ * CCTV Stream Registry Util; 외부 구독용 immutable snapshot 생성
113
+ * @util
114
+ * @param {CctvRtcStreamEntry} [entry] snapshot을 만들 registry entry
115
+ * @desc Map에 entry가 없을 때는 새 연결 전 기본 상태를 반환한다.
116
+ * @return {CctvRtcStreamSnapshot} 현재 entry 상태 또는 기본 idle 상태
117
+ */
29
118
  const getEntrySnapshot = (
30
119
  entry?: CctvRtcStreamEntry,
31
120
  ): CctvRtcStreamSnapshot => ({
@@ -35,12 +124,26 @@ const getEntrySnapshot = (
35
124
  streamError: entry?.streamError ?? null,
36
125
  });
37
126
 
127
+ /**
128
+ * CCTV Stream Registry Util; video element에 RTC stream 연결
129
+ * @util
130
+ * @param {HTMLVideoElement} video stream attach 대상 video
131
+ * @param {MediaStream | null} stream 표시할 MediaStream 또는 초기화용 null
132
+ * @desc inline autoplay 속성과 srcObject를 함께 적용한다.
133
+ */
38
134
  const attachVideo = (video: HTMLVideoElement, stream: MediaStream | null) => {
39
135
  video.playsInline = true;
40
136
  video.autoplay = true;
41
137
  video.srcObject = stream;
42
138
  };
43
139
 
140
+ /**
141
+ * CCTV Stream Registry Util; 지정 stream의 video 연결 해제
142
+ * @util
143
+ * @param {HTMLVideoElement} video cleanup 대상 video
144
+ * @param {MediaStream | null} stream 제거 대상 MediaStream
145
+ * @desc replacement stream이 이미 붙은 video를 이전 entry cleanup이 비우지 않게 한다.
146
+ */
44
147
  const clearVideoIfAttachedStream = (
45
148
  video: HTMLVideoElement,
46
149
  stream: MediaStream | null,
@@ -50,12 +153,25 @@ const clearVideoIfAttachedStream = (
50
153
  }
51
154
  };
52
155
 
156
+ /**
157
+ * CCTV Stream Registry Util; entry snapshot 구독자 통지
158
+ * @util
159
+ * @param {CctvRtcStreamEntry} entry 변경된 registry entry
160
+ * @desc 현재 entry의 immutable snapshot을 생성해 등록된 listener에 전달한다.
161
+ */
53
162
  const notifyEntry = (entry: CctvRtcStreamEntry) => {
54
163
  const snapshot = getEntrySnapshot(entry);
55
164
  entry.listeners.forEach(listener => listener(snapshot));
56
165
  };
57
166
 
167
+ /**
168
+ * CCTV Stream Registry Util; 단일 registry entry resource 정리
169
+ * @util
170
+ * @param {CctvRtcStreamEntry} entry 종료할 registry entry
171
+ * @desc AbortController와 WHEP handle cleanup은 각 구현의 idempotent 계약을 재사용한다.
172
+ */
58
173
  const closeEntry = (entry: CctvRtcStreamEntry) => {
174
+ // pending WHEP와 연결 완료 handle을 모두 닫되 다음 close가 중복 실행되지 않게 참조를 비운다.
59
175
  entry.controller?.abort();
60
176
  entry.controller = null;
61
177
  entry.handle?.close();
@@ -64,59 +180,135 @@ const closeEntry = (entry: CctvRtcStreamEntry) => {
64
180
  entry.isStreaming = false;
65
181
 
66
182
  entry.attachedVideos.forEach(video => {
67
- clearVideoIfAttachedStream(video, entry.stream);
183
+ // committed 전 replacement도 현재 entry가 video 붙인 resource로 보고 선택적으로 제거한다.
184
+ clearVideoIfAttachedStream(video, entry.pendingStream ?? entry.stream);
68
185
  });
186
+ entry.pendingStream = null;
187
+ entry.previousVideoSources.clear();
69
188
 
189
+ // 마지막 closed snapshot을 전달한 뒤 entry가 외부 객체를 붙잡지 않도록 집합을 비운다.
70
190
  notifyEntry(entry);
71
191
  entry.attachedVideos.clear();
72
192
  entry.listeners.clear();
73
193
  };
74
194
 
195
+ /**
196
+ * CCTV Stream Registry Types; Provider scope registry 계약
197
+ * @property {(streamKey: string, video: HTMLVideoElement) => () => void} attach video attach와 detach cleanup 등록
198
+ * @property {() => void} closeAll Provider scope의 모든 stream 종료
199
+ * @property {(identityKey: string, exceptStreamKey?: string) => void} closeByIdentity 같은 camera family의 stream 종료
200
+ * @property {(streamKey: string) => CctvRtcStreamSnapshot} getSnapshot stream snapshot 조회
201
+ * @property {(params: CctvRtcStreamStartParams) => void} start streamKey 단위 WHEP 시작
202
+ * @property {(streamKey: string, listener: (snapshot: CctvRtcStreamSnapshot) => void) => () => void} subscribe stream snapshot 구독
203
+ * @desc Provider scope에서 WHEP stream 시작, 연결, 구독과 cleanup을 관리하는 공개 registry 계약
204
+ */
75
205
  export interface CctvRtcStreamRegistry {
206
+ /**
207
+ * video attach와 detach cleanup 등록
208
+ */
76
209
  attach: (streamKey: string, video: HTMLVideoElement) => () => void;
210
+ /**
211
+ * Provider scope의 모든 stream 종료
212
+ */
77
213
  closeAll: () => void;
214
+ /**
215
+ * 같은 camera family의 stream을 optional 예외 key 외에는 모두 종료
216
+ */
78
217
  closeByIdentity: (identityKey: string, exceptStreamKey?: string) => void;
218
+ /**
219
+ * streamKey의 현재 외부 구독 snapshot 조회
220
+ */
79
221
  getSnapshot: (streamKey: string) => CctvRtcStreamSnapshot;
222
+ /**
223
+ * streamKey 단위 WHEP 시작
224
+ */
80
225
  start: (params: CctvRtcStreamStartParams) => void;
226
+ /**
227
+ * streamKey snapshot 구독과 unsubscribe cleanup 등록
228
+ */
81
229
  subscribe: (
82
230
  streamKey: string,
83
231
  listener: (snapshot: CctvRtcStreamSnapshot) => void,
84
232
  ) => () => void;
85
233
  }
86
234
 
235
+ /**
236
+ * CCTV Stream Registry Util; Provider scope stream registry 생성
237
+ * @util
238
+ * @desc 같은 streamKey의 중복 WHEP start를 막고 camera identity별 replacement cleanup을 관리한다.
239
+ * @return {CctvRtcStreamRegistry} 독립된 Provider scope stream registry
240
+ * @example
241
+ * const registry = createCctvRtcStreamRegistry();
242
+ * registry.start({ endpoint, identityKey, streamKey, token, video });
243
+ */
87
244
  export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
245
+ // Map은 token generation이 포함된 streamKey마다 현재 lifecycle owner를 하나만 유지한다.
88
246
  const entries = new Map<string, CctvRtcStreamEntry>();
89
247
 
248
+ // entry resource를 정리한 뒤 streamKey의 Map owner를 제거한다.
90
249
  const deleteEntry = (entry: CctvRtcStreamEntry) => {
91
250
  closeEntry(entry);
92
251
  entries.delete(entry.streamKey);
93
252
  };
94
253
 
95
254
  return {
255
+ /**
256
+ * CCTV Stream Registry Util; streamKey entry에 video 연결
257
+ * @util
258
+ * @param {string} streamKey attach할 stream key
259
+ * @param {HTMLVideoElement} video attach 대상 video
260
+ * @desc 현재 stream을 video에 연결하고 attach 집합에 등록한다.
261
+ * @return {() => void} 현재 video attach만 해제하는 cleanup
262
+ */
96
263
  attach(streamKey, video) {
97
264
  const entry = entries.get(streamKey);
98
265
  if (!entry) {
266
+ // 아직 시작되지 않은 key는 stale srcObject 없이 빈 video 상태로 정규화한다.
99
267
  attachVideo(video, null);
268
+ // 소유할 registry entry가 없으므로 Map mutation 없이 현재 video만 비우는 cleanup을 반환한다.
100
269
  return () => {
101
270
  if (video.srcObject) video.srcObject = null;
102
271
  };
103
272
  }
104
273
 
274
+ // 기존 MediaStream이 있으면 즉시 reattach하고 pending entry면 현재 화면을 보존한다.
105
275
  entry.attachedVideos.add(video);
106
- if (entry.stream || !video.srcObject) {
107
- attachVideo(video, entry.stream);
276
+ if (!entry.previousVideoSources.has(video)) {
277
+ // pending replacement 실패 시 이 attach 직전 source로 돌아갈 수 있게 최초 값만 보관한다.
278
+ entry.previousVideoSources.set(video, video.srcObject);
279
+ }
280
+ // handle 성공 전 track도 새 subscriber video에는 보이되 committed snapshot으로 승격하지 않는다.
281
+ const attachedStream = entry.stream ?? entry.pendingStream;
282
+ if (attachedStream || !video.srcObject) {
283
+ attachVideo(video, attachedStream);
108
284
  }
109
285
 
110
286
  return () => {
287
+ // detach된 video는 이후 실패 복원과 entry cleanup의 소유 대상에서 제외한다.
111
288
  entry.attachedVideos.delete(video);
112
- clearVideoIfAttachedStream(video, entry.stream);
289
+ entry.previousVideoSources.delete(video);
290
+ clearVideoIfAttachedStream(video, entry.pendingStream ?? entry.stream);
113
291
  };
114
292
  },
293
+ /**
294
+ * CCTV Stream Registry Util; Provider scope의 모든 stream 정리
295
+ * @util
296
+ * @desc 모든 entry resource를 닫고 registry Map을 비운다.
297
+ */
115
298
  closeAll() {
299
+ // Provider unmount에서 모든 entry를 한 번 닫은 뒤 registry owner Map을 비운다.
116
300
  entries.forEach(entry => closeEntry(entry));
117
301
  entries.clear();
118
302
  },
303
+ /**
304
+ * CCTV Stream Registry Util; 같은 identity의 이전 stream 정리
305
+ * @util
306
+ * @param {string} identityKey cleanup할 camera stream family key
307
+ * @param {string} [exceptStreamKey] 유지할 replacement stream key
308
+ * @desc optional 예외 key를 제외한 같은 camera family entry를 닫는다.
309
+ */
119
310
  closeByIdentity(identityKey, exceptStreamKey) {
311
+ // replacement track이 붙은 뒤 같은 camera family의 이전 generation만 선택적으로 닫는다.
120
312
  entries.forEach(entry => {
121
313
  if (
122
314
  entry.identityKey === identityKey &&
@@ -126,15 +318,35 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
126
318
  }
127
319
  });
128
320
  },
321
+ /**
322
+ * CCTV Stream Registry Util; streamKey snapshot 조회
323
+ * @util
324
+ * @param {string} streamKey 조회할 stream key
325
+ * @desc 내부 mutable entry를 노출하지 않고 외부 구독용 snapshot만 반환한다.
326
+ * @return {CctvRtcStreamSnapshot} 현재 entry 상태 또는 기본 idle 상태
327
+ */
129
328
  getSnapshot(streamKey) {
329
+ // 외부 consumer가 내부 mutable entry를 직접 소유하지 않도록 snapshot만 반환한다.
130
330
  return getEntrySnapshot(entries.get(streamKey));
131
331
  },
332
+ /**
333
+ * CCTV Stream Registry Util; streamKey 단위 WHEP 연결 시작
334
+ * @util
335
+ * @property {string} params.endpoint token-specific WHEP 요청 endpoint
336
+ * @property {string} params.identityKey camera와 endpoint 기준 stream family key
337
+ * @property {string} params.streamKey token generation까지 포함한 stream key
338
+ * @property {string} params.token WHEP Bearer token
339
+ * @property {HTMLVideoElement} params.video 최초 stream attach 대상 video element
340
+ * @desc 같은 streamKey의 중복 start를 막고 현재 entry의 lifecycle만 갱신한다.
341
+ */
132
342
  start({ endpoint, identityKey, streamKey, token, video }) {
133
343
  const existingEntry = entries.get(streamKey);
134
344
  if (existingEntry) {
345
+ // 같은 token generation의 concurrent attach는 기존 WHEP start 하나를 공유한다.
135
346
  return;
136
347
  }
137
348
 
349
+ // WHEP promise보다 entry를 먼저 등록해 같은 tick의 중복 start를 차단한다.
138
350
  const controller = new AbortController();
139
351
  const entry: CctvRtcStreamEntry = {
140
352
  attachedVideos: new Set([video]),
@@ -144,13 +356,21 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
144
356
  identityKey,
145
357
  isStreaming: true,
146
358
  listeners: new Set(),
359
+ pendingStream: null,
360
+ // 최초 video가 이미 표시하던 connected stream을 replacement 실패 rollback 값으로 보존한다.
361
+ previousVideoSources: new Map([[video, video.srcObject]]),
147
362
  stream: null,
148
363
  streamError: null,
149
364
  streamKey,
150
365
  };
151
366
 
152
367
  entries.set(streamKey, entry);
368
+ // Map owner 일치와 AbortSignal 상태로 현재 entry의 lifecycle 소유권을 확인한다.
369
+ const isCurrentEntry = () =>
370
+ entries.get(streamKey) === entry && !controller.signal.aborted;
371
+
153
372
  if (!video.srcObject) {
373
+ // cold start는 빈 video를 초기화하고 replacement는 기존 화면을 그대로 유지한다.
154
374
  attachVideo(video, null);
155
375
  } else {
156
376
  video.playsInline = true;
@@ -163,41 +383,90 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
163
383
  video,
164
384
  signal: controller.signal,
165
385
  onConnectionStateChange: state => {
386
+ if (!isCurrentEntry()) {
387
+ // Provider cleanup 또는 같은 key owner 교체 뒤 늦은 state event는 반영하지 않는다.
388
+ return;
389
+ }
390
+
166
391
  entry.connectionState = state;
167
392
  notifyEntry(entry);
168
393
  },
169
394
  onTrack: event => {
170
- entry.stream = event.streams[0] ?? null;
395
+ if (!isCurrentEntry()) {
396
+ // 닫힌 entry의 late track이 video와 snapshot을 되살리지 않게 막는다.
397
+ return;
398
+ }
399
+
400
+ // track만으로 성공을 확정하지 않고 handle 반환 전까지 pending resource로 유지한다.
401
+ entry.pendingStream = event.streams[0] ?? null;
171
402
  entry.attachedVideos.forEach(attachedVideo =>
172
- attachVideo(attachedVideo, entry.stream),
403
+ attachVideo(attachedVideo, entry.pendingStream),
173
404
  );
174
- notifyEntry(entry);
175
405
  },
176
406
  })
177
407
  .then(handle => {
408
+ // pending 중 close된 start가 늦게 resolve되면 새 handle을 즉시 회수한다.
409
+ if (!isCurrentEntry()) {
410
+ handle.close();
411
+ // 회수한 stale handle을 현재 entry의 성공 상태로 다시 기록하지 않는다.
412
+ return;
413
+ }
414
+
178
415
  entry.handle = handle;
179
416
  entry.isStreaming = false;
417
+ // remote SDP까지 완료된 handle만 committed snapshot으로 승격해 이전 generation을 닫게 한다.
418
+ entry.stream = entry.pendingStream;
419
+ entry.pendingStream = null;
180
420
  notifyEntry(entry);
421
+ entry.previousVideoSources.clear();
181
422
  })
182
423
  .catch(error => {
424
+ if (entries.get(streamKey) !== entry) {
425
+ // abort/delete된 이전 owner의 rejection은 현재 entry 상태를 덮어쓰지 않는다.
426
+ return;
427
+ }
428
+
429
+ // 실패 원인이 어느 negotiation 단계이든 util-rtc 내부 PeerConnection cleanup을 요청한다.
430
+ entry.controller?.abort();
183
431
  entry.controller = null;
184
432
  entry.handle = null;
185
433
  entry.isStreaming = false;
434
+ const failedStream = entry.pendingStream ?? entry.stream;
435
+ entry.attachedVideos.forEach(attachedVideo => {
436
+ // 외부에서 다른 source로 교체한 video는 건드리지 않고 이 entry의 실패 stream만 rollback한다.
437
+ if (failedStream && attachedVideo.srcObject === failedStream) {
438
+ attachedVideo.srcObject =
439
+ entry.previousVideoSources.get(attachedVideo) ?? null;
440
+ }
441
+ });
442
+ entry.pendingStream = null;
186
443
  entry.stream = null;
444
+ entry.previousVideoSources.clear();
187
445
  entry.streamError =
188
446
  error instanceof Error
189
447
  ? error.message
190
448
  : "스트림 연결에 실패했습니다.";
191
- entry.attachedVideos.forEach(attachedVideo =>
192
- clearVideoIfAttachedStream(attachedVideo, entry.stream),
193
- );
449
+ // subscriber에는 terminal error를 전달하되 실패 entry 다음 명시적 start를 막지 않는다.
194
450
  notifyEntry(entry);
451
+ entries.delete(streamKey);
195
452
  });
196
453
  },
454
+ /**
455
+ * CCTV Stream Registry Util; streamKey snapshot listener 등록
456
+ * @util
457
+ * @param {string} streamKey 구독할 stream key
458
+ * @param {(snapshot: CctvRtcStreamSnapshot) => void} listener snapshot listener
459
+ * @desc 등록 직후 현재 snapshot을 전달하고 이후 상태 변경을 구독한다.
460
+ * @return {() => void} 현재 listener만 제거하는 unsubscribe
461
+ */
197
462
  subscribe(streamKey, listener) {
198
463
  const entry = entries.get(streamKey);
199
- if (!entry) return () => undefined;
464
+ if (!entry) {
465
+ // 구독할 lifecycle owner가 없으므로 listener를 저장하지 않고 no-op cleanup을 반환한다.
466
+ return () => undefined;
467
+ }
200
468
 
469
+ // 구독 직후 현재 snapshot을 전달해 registry reattach의 첫 render 지연을 줄인다.
201
470
  entry.listeners.add(listener);
202
471
  listener(getEntrySnapshot(entry));
203
472