connectbase-client 5.13.0 → 6.0.0

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/index.d.mts CHANGED
@@ -7403,9 +7403,31 @@ declare class RealtimeAPI {
7403
7403
  */
7404
7404
  getState(): ConnectionState;
7405
7405
  /**
7406
- * 연결 여부 확인
7406
+ * pub/sub 사용 가능 여부.
7407
+ *
7408
+ * WebSocket 으로 연결됐을 때만 true 다. SSE fallback 모드에서는 `subscribe` /
7409
+ * `sendMessage` / `setPresence` / typing 이 전부 예외를 던지므로, 여기서 true 를
7410
+ * 반환하면 앱이 "연결됨"으로 오판하고 아무것도 오가지 않는 채 조용히 멈춘다
7411
+ * (platform-issue 01a01e6a-3cb1).
7412
+ *
7413
+ * AI 스트리밍만 필요하다면 {@link isStreamReady} 를, 다운그레이드 여부를 알고 싶다면
7414
+ * {@link isDegraded} 를 쓴다.
7407
7415
  */
7408
7416
  isConnected(): boolean;
7417
+ /**
7418
+ * SSE fallback 으로 다운그레이드된 상태인지 여부.
7419
+ *
7420
+ * true 면 연결은 살아 있지만 단방향이라 `realtime.stream()` 만 동작한다. 사용자에게
7421
+ * "제한된 네트워크라 일부 기능을 쓸 수 없다" 고 안내할 지점이다.
7422
+ */
7423
+ isDegraded(): boolean;
7424
+ /**
7425
+ * AI 스트리밍(`realtime.stream()`) 사용 가능 여부.
7426
+ *
7427
+ * WS 든 SSE fallback 이든 연결만 서 있으면 true 다. 종전 `isConnected()` 와 같은
7428
+ * 판정이므로, 스트리밍 게이팅에 `isConnected()` 를 쓰던 코드는 이쪽으로 옮긴다.
7429
+ */
7430
+ isStreamReady(): boolean;
7409
7431
  /**
7410
7432
  * 상태 변경 핸들러 등록
7411
7433
  */
@@ -10047,6 +10069,14 @@ interface ICEServer {
10047
10069
  /** ICE 서버 목록 응답 */
10048
10070
  interface ICEServersResponse {
10049
10071
  ice_servers: ICEServer[];
10072
+ /**
10073
+ * ephemeral TURN 자격증명의 만료 시각(Unix epoch 초).
10074
+ *
10075
+ * 서버가 `TURN_SHARED_SECRET` 으로 시간제한 자격증명을 발급한 경우에만 0 이 아니다.
10076
+ * STUN 전용 구성에서는 0 — 만료가 없다는 뜻이다. 통화가 길어질 수 있으면 이 시각
10077
+ * 전에 `getICEServers()` 를 다시 불러 새 자격증명을 받는다.
10078
+ */
10079
+ expires_at?: number;
10050
10080
  }
10051
10081
  /**
10052
10082
  * 채널별 통계 (룸 ID 의 `channel:room` 컨벤션 기준 집계).
@@ -10122,6 +10152,16 @@ declare class WebRTCAPI {
10122
10152
  private reconnectAttempts;
10123
10153
  private maxReconnectAttempts;
10124
10154
  private reconnectTimeout;
10155
+ /**
10156
+ * 시그널링 소켓의 세대(generation) 토큰.
10157
+ *
10158
+ * 소켓을 열 때마다 1 증가하며, 각 핸들러는 자기가 열릴 때의 세대를 클로저에 담아둔다.
10159
+ * 세대가 밀린 핸들러는 "옛 소켓의 지연 이벤트" 이므로 인스턴스 상태를 건드리지 않는다.
10160
+ * 참조(`this.ws`)만 끊는 것으로는 부족하다 — 핸들러 클로저가 인스턴스를 캡처하고 있어
10161
+ * close 이벤트가 뒤늦게 도착하면 새 연결을 'disconnected' 로 덮어쓰고 새로 만든 피어
10162
+ * 연결까지 정리해버린다.
10163
+ */
10164
+ private wsGeneration;
10125
10165
  private currentRoomId;
10126
10166
  private currentPeerId;
10127
10167
  private currentUserId;
@@ -10158,6 +10198,14 @@ declare class WebRTCAPI {
10158
10198
  */
10159
10199
  connect(options: WebRTCConnectOptions): Promise<void>;
10160
10200
  private connectWebSocket;
10201
+ /**
10202
+ * 소켓에 붙은 핸들러를 전부 떼어낸다.
10203
+ *
10204
+ * `this.ws = null` 로 참조만 끊으면 클로저가 살아남아 close 이벤트가 계속 들어온다.
10205
+ * 세대 토큰이 그 이벤트를 무시해주긴 하지만, 핸들러 자체를 떼면 인스턴스 참조가 끊겨
10206
+ * 소켓이 GC 될 때까지 인스턴스를 붙잡고 있지 않는다.
10207
+ */
10208
+ private static detachSocket;
10161
10209
  private buildWebSocketUrl;
10162
10210
  private handleSignalingMessage;
10163
10211
  private createPeerConnection;
@@ -10172,6 +10220,44 @@ declare class WebRTCAPI {
10172
10220
  * WebRTC 연결 해제
10173
10221
  */
10174
10222
  disconnect(): void;
10223
+ /**
10224
+ * 현재 세션의 룸을 교체한다.
10225
+ *
10226
+ * `disconnect()` 후 곧바로 `connect()` 하는 패턴을 안전하게 감싼 것이다. 옛 소켓의
10227
+ * close 이벤트가 새 연결의 'connecting' 구간에 떨어져도 세대 토큰이 걸러낸다.
10228
+ * 생략한 옵션은 현재 세션 값을 그대로 이어받는다.
10229
+ *
10230
+ * @example
10231
+ * ```typescript
10232
+ * await cb.webrtc.switchRoom('channel:room-b')
10233
+ * ```
10234
+ */
10235
+ switchRoom(roomId: string, options?: Omit<WebRTCConnectOptions, "roomId">): Promise<void>;
10236
+ /**
10237
+ * 소켓, 룸, 이벤트 리스너, 피어 연결을 **독립적으로** 갖는 새 WebRTC 세션을 만든다.
10238
+ *
10239
+ * `cb.webrtc` 는 인스턴스 하나에 소켓 하나/룸 하나라, 한 앱에 WebRTC 기능이 둘 이상이면
10240
+ * (예: 공간 음성채팅 + 1:1 통화) 서로 연결을 뺏고 이벤트가 교차한다. 기능마다 세션을
10241
+ * 하나씩 만들면 각자의 소켓과 핸들러를 갖는다.
10242
+ *
10243
+ * 인증 정보(HttpClient)와 서버 주소, appId 는 부모와 공유한다.
10244
+ *
10245
+ * @example
10246
+ * ```typescript
10247
+ * const voice = cb.webrtc.createSession()
10248
+ * const call = cb.webrtc.createSession()
10249
+ *
10250
+ * voice.onRemoteStream((peerId, stream) => attachToSpatialAudio(peerId, stream))
10251
+ * call.onRemoteStream((peerId, stream) => showCallScreen(stream))
10252
+ *
10253
+ * await voice.connect({ roomId: 'voice:lobby', localStream: mic })
10254
+ * await call.connect({ roomId: 'call:alice-bob', localStream: mic })
10255
+ *
10256
+ * // 각 세션은 독립적으로 끊는다
10257
+ * call.disconnect()
10258
+ * ```
10259
+ */
10260
+ createSession(): WebRTCAPI;
10175
10261
  /**
10176
10262
  * 현재 연결 상태 조회
10177
10263
  */
package/dist/index.d.ts CHANGED
@@ -7403,9 +7403,31 @@ declare class RealtimeAPI {
7403
7403
  */
7404
7404
  getState(): ConnectionState;
7405
7405
  /**
7406
- * 연결 여부 확인
7406
+ * pub/sub 사용 가능 여부.
7407
+ *
7408
+ * WebSocket 으로 연결됐을 때만 true 다. SSE fallback 모드에서는 `subscribe` /
7409
+ * `sendMessage` / `setPresence` / typing 이 전부 예외를 던지므로, 여기서 true 를
7410
+ * 반환하면 앱이 "연결됨"으로 오판하고 아무것도 오가지 않는 채 조용히 멈춘다
7411
+ * (platform-issue 01a01e6a-3cb1).
7412
+ *
7413
+ * AI 스트리밍만 필요하다면 {@link isStreamReady} 를, 다운그레이드 여부를 알고 싶다면
7414
+ * {@link isDegraded} 를 쓴다.
7407
7415
  */
7408
7416
  isConnected(): boolean;
7417
+ /**
7418
+ * SSE fallback 으로 다운그레이드된 상태인지 여부.
7419
+ *
7420
+ * true 면 연결은 살아 있지만 단방향이라 `realtime.stream()` 만 동작한다. 사용자에게
7421
+ * "제한된 네트워크라 일부 기능을 쓸 수 없다" 고 안내할 지점이다.
7422
+ */
7423
+ isDegraded(): boolean;
7424
+ /**
7425
+ * AI 스트리밍(`realtime.stream()`) 사용 가능 여부.
7426
+ *
7427
+ * WS 든 SSE fallback 이든 연결만 서 있으면 true 다. 종전 `isConnected()` 와 같은
7428
+ * 판정이므로, 스트리밍 게이팅에 `isConnected()` 를 쓰던 코드는 이쪽으로 옮긴다.
7429
+ */
7430
+ isStreamReady(): boolean;
7409
7431
  /**
7410
7432
  * 상태 변경 핸들러 등록
7411
7433
  */
@@ -10047,6 +10069,14 @@ interface ICEServer {
10047
10069
  /** ICE 서버 목록 응답 */
10048
10070
  interface ICEServersResponse {
10049
10071
  ice_servers: ICEServer[];
10072
+ /**
10073
+ * ephemeral TURN 자격증명의 만료 시각(Unix epoch 초).
10074
+ *
10075
+ * 서버가 `TURN_SHARED_SECRET` 으로 시간제한 자격증명을 발급한 경우에만 0 이 아니다.
10076
+ * STUN 전용 구성에서는 0 — 만료가 없다는 뜻이다. 통화가 길어질 수 있으면 이 시각
10077
+ * 전에 `getICEServers()` 를 다시 불러 새 자격증명을 받는다.
10078
+ */
10079
+ expires_at?: number;
10050
10080
  }
10051
10081
  /**
10052
10082
  * 채널별 통계 (룸 ID 의 `channel:room` 컨벤션 기준 집계).
@@ -10122,6 +10152,16 @@ declare class WebRTCAPI {
10122
10152
  private reconnectAttempts;
10123
10153
  private maxReconnectAttempts;
10124
10154
  private reconnectTimeout;
10155
+ /**
10156
+ * 시그널링 소켓의 세대(generation) 토큰.
10157
+ *
10158
+ * 소켓을 열 때마다 1 증가하며, 각 핸들러는 자기가 열릴 때의 세대를 클로저에 담아둔다.
10159
+ * 세대가 밀린 핸들러는 "옛 소켓의 지연 이벤트" 이므로 인스턴스 상태를 건드리지 않는다.
10160
+ * 참조(`this.ws`)만 끊는 것으로는 부족하다 — 핸들러 클로저가 인스턴스를 캡처하고 있어
10161
+ * close 이벤트가 뒤늦게 도착하면 새 연결을 'disconnected' 로 덮어쓰고 새로 만든 피어
10162
+ * 연결까지 정리해버린다.
10163
+ */
10164
+ private wsGeneration;
10125
10165
  private currentRoomId;
10126
10166
  private currentPeerId;
10127
10167
  private currentUserId;
@@ -10158,6 +10198,14 @@ declare class WebRTCAPI {
10158
10198
  */
10159
10199
  connect(options: WebRTCConnectOptions): Promise<void>;
10160
10200
  private connectWebSocket;
10201
+ /**
10202
+ * 소켓에 붙은 핸들러를 전부 떼어낸다.
10203
+ *
10204
+ * `this.ws = null` 로 참조만 끊으면 클로저가 살아남아 close 이벤트가 계속 들어온다.
10205
+ * 세대 토큰이 그 이벤트를 무시해주긴 하지만, 핸들러 자체를 떼면 인스턴스 참조가 끊겨
10206
+ * 소켓이 GC 될 때까지 인스턴스를 붙잡고 있지 않는다.
10207
+ */
10208
+ private static detachSocket;
10161
10209
  private buildWebSocketUrl;
10162
10210
  private handleSignalingMessage;
10163
10211
  private createPeerConnection;
@@ -10172,6 +10220,44 @@ declare class WebRTCAPI {
10172
10220
  * WebRTC 연결 해제
10173
10221
  */
10174
10222
  disconnect(): void;
10223
+ /**
10224
+ * 현재 세션의 룸을 교체한다.
10225
+ *
10226
+ * `disconnect()` 후 곧바로 `connect()` 하는 패턴을 안전하게 감싼 것이다. 옛 소켓의
10227
+ * close 이벤트가 새 연결의 'connecting' 구간에 떨어져도 세대 토큰이 걸러낸다.
10228
+ * 생략한 옵션은 현재 세션 값을 그대로 이어받는다.
10229
+ *
10230
+ * @example
10231
+ * ```typescript
10232
+ * await cb.webrtc.switchRoom('channel:room-b')
10233
+ * ```
10234
+ */
10235
+ switchRoom(roomId: string, options?: Omit<WebRTCConnectOptions, "roomId">): Promise<void>;
10236
+ /**
10237
+ * 소켓, 룸, 이벤트 리스너, 피어 연결을 **독립적으로** 갖는 새 WebRTC 세션을 만든다.
10238
+ *
10239
+ * `cb.webrtc` 는 인스턴스 하나에 소켓 하나/룸 하나라, 한 앱에 WebRTC 기능이 둘 이상이면
10240
+ * (예: 공간 음성채팅 + 1:1 통화) 서로 연결을 뺏고 이벤트가 교차한다. 기능마다 세션을
10241
+ * 하나씩 만들면 각자의 소켓과 핸들러를 갖는다.
10242
+ *
10243
+ * 인증 정보(HttpClient)와 서버 주소, appId 는 부모와 공유한다.
10244
+ *
10245
+ * @example
10246
+ * ```typescript
10247
+ * const voice = cb.webrtc.createSession()
10248
+ * const call = cb.webrtc.createSession()
10249
+ *
10250
+ * voice.onRemoteStream((peerId, stream) => attachToSpatialAudio(peerId, stream))
10251
+ * call.onRemoteStream((peerId, stream) => showCallScreen(stream))
10252
+ *
10253
+ * await voice.connect({ roomId: 'voice:lobby', localStream: mic })
10254
+ * await call.connect({ roomId: 'call:alice-bob', localStream: mic })
10255
+ *
10256
+ * // 각 세션은 독립적으로 끊는다
10257
+ * call.disconnect()
10258
+ * ```
10259
+ */
10260
+ createSession(): WebRTCAPI;
10175
10261
  /**
10176
10262
  * 현재 연결 상태 조회
10177
10263
  */
package/dist/index.js CHANGED
@@ -7936,9 +7936,35 @@ var RealtimeAPI = class {
7936
7936
  return this.state;
7937
7937
  }
7938
7938
  /**
7939
- * 연결 여부 확인
7939
+ * pub/sub 사용 가능 여부.
7940
+ *
7941
+ * WebSocket 으로 연결됐을 때만 true 다. SSE fallback 모드에서는 `subscribe` /
7942
+ * `sendMessage` / `setPresence` / typing 이 전부 예외를 던지므로, 여기서 true 를
7943
+ * 반환하면 앱이 "연결됨"으로 오판하고 아무것도 오가지 않는 채 조용히 멈춘다
7944
+ * (platform-issue 01a01e6a-3cb1).
7945
+ *
7946
+ * AI 스트리밍만 필요하다면 {@link isStreamReady} 를, 다운그레이드 여부를 알고 싶다면
7947
+ * {@link isDegraded} 를 쓴다.
7940
7948
  */
7941
7949
  isConnected() {
7950
+ return this.state === "connected" && this.activeTransport === "ws";
7951
+ }
7952
+ /**
7953
+ * SSE fallback 으로 다운그레이드된 상태인지 여부.
7954
+ *
7955
+ * true 면 연결은 살아 있지만 단방향이라 `realtime.stream()` 만 동작한다. 사용자에게
7956
+ * "제한된 네트워크라 일부 기능을 쓸 수 없다" 고 안내할 지점이다.
7957
+ */
7958
+ isDegraded() {
7959
+ return this.sseFallbackActive;
7960
+ }
7961
+ /**
7962
+ * AI 스트리밍(`realtime.stream()`) 사용 가능 여부.
7963
+ *
7964
+ * WS 든 SSE fallback 이든 연결만 서 있으면 true 다. 종전 `isConnected()` 와 같은
7965
+ * 판정이므로, 스트리밍 게이팅에 `isConnected()` 를 쓰던 코드는 이쪽으로 옮긴다.
7966
+ */
7967
+ isStreamReady() {
7942
7968
  return this.state === "connected";
7943
7969
  }
7944
7970
  /**
@@ -11069,7 +11095,7 @@ var VideoAPI = class {
11069
11095
  };
11070
11096
 
11071
11097
  // src/api/webrtc.ts
11072
- var WebRTCAPI = class {
11098
+ var WebRTCAPI = class _WebRTCAPI {
11073
11099
  constructor(http, webrtcUrl, appId) {
11074
11100
  this.ws = null;
11075
11101
  this.state = "disconnected";
@@ -11081,6 +11107,16 @@ var WebRTCAPI = class {
11081
11107
  this.reconnectAttempts = 0;
11082
11108
  this.maxReconnectAttempts = 5;
11083
11109
  this.reconnectTimeout = null;
11110
+ /**
11111
+ * 시그널링 소켓의 세대(generation) 토큰.
11112
+ *
11113
+ * 소켓을 열 때마다 1 증가하며, 각 핸들러는 자기가 열릴 때의 세대를 클로저에 담아둔다.
11114
+ * 세대가 밀린 핸들러는 "옛 소켓의 지연 이벤트" 이므로 인스턴스 상태를 건드리지 않는다.
11115
+ * 참조(`this.ws`)만 끊는 것으로는 부족하다 — 핸들러 클로저가 인스턴스를 캡처하고 있어
11116
+ * close 이벤트가 뒤늦게 도착하면 새 연결을 'disconnected' 로 덮어쓰고 새로 만든 피어
11117
+ * 연결까지 정리해버린다.
11118
+ */
11119
+ this.wsGeneration = 0;
11084
11120
  // 현재 연결 정보
11085
11121
  this.currentRoomId = null;
11086
11122
  this.currentPeerId = null;
@@ -11153,15 +11189,23 @@ var WebRTCAPI = class {
11153
11189
  connectWebSocket() {
11154
11190
  return new Promise((resolve, reject) => {
11155
11191
  const wsUrl = this.buildWebSocketUrl();
11156
- this.ws = new WebSocket(wsUrl);
11192
+ const generation = ++this.wsGeneration;
11193
+ const ws = new WebSocket(wsUrl);
11194
+ this.ws = ws;
11195
+ const isStale = () => this.wsGeneration !== generation;
11157
11196
  const timeout = setTimeout(() => {
11197
+ if (isStale()) return;
11158
11198
  if (this.state === "connecting") {
11159
- this.ws?.close();
11199
+ ws.close();
11160
11200
  reject(new Error("\uC5F0\uACB0 \uC2DC\uAC04 \uCD08\uACFC"));
11161
11201
  }
11162
11202
  }, 1e4);
11163
- this.ws.onopen = () => {
11203
+ ws.onopen = () => {
11164
11204
  clearTimeout(timeout);
11205
+ if (isStale()) {
11206
+ ws.close(1e3, "superseded");
11207
+ return;
11208
+ }
11165
11209
  this.reconnectAttempts = 0;
11166
11210
  this.sendSignaling({
11167
11211
  type: "join",
@@ -11172,7 +11216,8 @@ var WebRTCAPI = class {
11172
11216
  }
11173
11217
  });
11174
11218
  };
11175
- this.ws.onmessage = async (event) => {
11219
+ ws.onmessage = async (event) => {
11220
+ if (isStale()) return;
11176
11221
  try {
11177
11222
  const msg = JSON.parse(event.data);
11178
11223
  await this.handleSignalingMessage(msg, resolve, reject);
@@ -11180,13 +11225,15 @@ var WebRTCAPI = class {
11180
11225
  console.error("Failed to parse signaling message:", error);
11181
11226
  }
11182
11227
  };
11183
- this.ws.onerror = (event) => {
11228
+ ws.onerror = (event) => {
11184
11229
  clearTimeout(timeout);
11230
+ if (isStale()) return;
11185
11231
  console.error("WebSocket error:", event);
11186
11232
  this.emitError(new Error("WebSocket \uC5F0\uACB0 \uC624\uB958"));
11187
11233
  };
11188
- this.ws.onclose = (event) => {
11234
+ ws.onclose = (event) => {
11189
11235
  clearTimeout(timeout);
11236
+ if (isStale()) return;
11190
11237
  if (this.state === "connecting") {
11191
11238
  reject(new Error("\uC5F0\uACB0\uC774 \uC885\uB8CC\uB418\uC5C8\uC2B5\uB2C8\uB2E4"));
11192
11239
  }
@@ -11194,6 +11241,19 @@ var WebRTCAPI = class {
11194
11241
  };
11195
11242
  });
11196
11243
  }
11244
+ /**
11245
+ * 소켓에 붙은 핸들러를 전부 떼어낸다.
11246
+ *
11247
+ * `this.ws = null` 로 참조만 끊으면 클로저가 살아남아 close 이벤트가 계속 들어온다.
11248
+ * 세대 토큰이 그 이벤트를 무시해주긴 하지만, 핸들러 자체를 떼면 인스턴스 참조가 끊겨
11249
+ * 소켓이 GC 될 때까지 인스턴스를 붙잡고 있지 않는다.
11250
+ */
11251
+ static detachSocket(ws) {
11252
+ ws.onopen = null;
11253
+ ws.onmessage = null;
11254
+ ws.onerror = null;
11255
+ ws.onclose = null;
11256
+ }
11197
11257
  buildWebSocketUrl() {
11198
11258
  const wsBase = this.webrtcUrl.replace("https://", "wss://").replace("http://", "ws://");
11199
11259
  const publicKey = this.http.getPublicKey();
@@ -11404,19 +11464,76 @@ var WebRTCAPI = class {
11404
11464
  clearTimeout(this.reconnectTimeout);
11405
11465
  this.reconnectTimeout = null;
11406
11466
  }
11407
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
11408
- this.sendSignaling({ type: "leave" });
11409
- this.ws.close(1e3, "User disconnected");
11467
+ this.reconnectAttempts = 0;
11468
+ this.wsGeneration++;
11469
+ const ws = this.ws;
11470
+ this.ws = null;
11471
+ if (ws) {
11472
+ _WebRTCAPI.detachSocket(ws);
11473
+ if (ws.readyState === WebSocket.OPEN) {
11474
+ try {
11475
+ ws.send(JSON.stringify({ type: "leave" }));
11476
+ } catch {
11477
+ }
11478
+ ws.close(1e3, "User disconnected");
11479
+ } else if (ws.readyState === WebSocket.CONNECTING) {
11480
+ ws.close();
11481
+ }
11410
11482
  }
11411
11483
  this.peerConnections.forEach((pc) => pc.close());
11412
11484
  this.peerConnections.clear();
11413
11485
  this.remoteStreams.clear();
11414
- this.ws = null;
11415
11486
  this.currentRoomId = null;
11416
11487
  this.currentPeerId = null;
11417
11488
  this.localStream = null;
11418
11489
  this.setState("disconnected");
11419
11490
  }
11491
+ /**
11492
+ * 현재 세션의 룸을 교체한다.
11493
+ *
11494
+ * `disconnect()` 후 곧바로 `connect()` 하는 패턴을 안전하게 감싼 것이다. 옛 소켓의
11495
+ * close 이벤트가 새 연결의 'connecting' 구간에 떨어져도 세대 토큰이 걸러낸다.
11496
+ * 생략한 옵션은 현재 세션 값을 그대로 이어받는다.
11497
+ *
11498
+ * @example
11499
+ * ```typescript
11500
+ * await cb.webrtc.switchRoom('channel:room-b')
11501
+ * ```
11502
+ */
11503
+ async switchRoom(roomId, options = {}) {
11504
+ const localStream = options.localStream ?? this.localStream ?? void 0;
11505
+ const userId = options.userId ?? this.currentUserId ?? void 0;
11506
+ const isBroadcaster = options.isBroadcaster ?? this.isBroadcaster;
11507
+ this.disconnect();
11508
+ await this.connect({ roomId, userId, isBroadcaster, localStream });
11509
+ }
11510
+ /**
11511
+ * 소켓, 룸, 이벤트 리스너, 피어 연결을 **독립적으로** 갖는 새 WebRTC 세션을 만든다.
11512
+ *
11513
+ * `cb.webrtc` 는 인스턴스 하나에 소켓 하나/룸 하나라, 한 앱에 WebRTC 기능이 둘 이상이면
11514
+ * (예: 공간 음성채팅 + 1:1 통화) 서로 연결을 뺏고 이벤트가 교차한다. 기능마다 세션을
11515
+ * 하나씩 만들면 각자의 소켓과 핸들러를 갖는다.
11516
+ *
11517
+ * 인증 정보(HttpClient)와 서버 주소, appId 는 부모와 공유한다.
11518
+ *
11519
+ * @example
11520
+ * ```typescript
11521
+ * const voice = cb.webrtc.createSession()
11522
+ * const call = cb.webrtc.createSession()
11523
+ *
11524
+ * voice.onRemoteStream((peerId, stream) => attachToSpatialAudio(peerId, stream))
11525
+ * call.onRemoteStream((peerId, stream) => showCallScreen(stream))
11526
+ *
11527
+ * await voice.connect({ roomId: 'voice:lobby', localStream: mic })
11528
+ * await call.connect({ roomId: 'call:alice-bob', localStream: mic })
11529
+ *
11530
+ * // 각 세션은 독립적으로 끊는다
11531
+ * call.disconnect()
11532
+ * ```
11533
+ */
11534
+ createSession() {
11535
+ return new _WebRTCAPI(this.http, this.webrtcUrl, this.appId);
11536
+ }
11420
11537
  /**
11421
11538
  * 현재 연결 상태 조회
11422
11539
  */
package/dist/index.mjs CHANGED
@@ -7887,9 +7887,35 @@ var RealtimeAPI = class {
7887
7887
  return this.state;
7888
7888
  }
7889
7889
  /**
7890
- * 연결 여부 확인
7890
+ * pub/sub 사용 가능 여부.
7891
+ *
7892
+ * WebSocket 으로 연결됐을 때만 true 다. SSE fallback 모드에서는 `subscribe` /
7893
+ * `sendMessage` / `setPresence` / typing 이 전부 예외를 던지므로, 여기서 true 를
7894
+ * 반환하면 앱이 "연결됨"으로 오판하고 아무것도 오가지 않는 채 조용히 멈춘다
7895
+ * (platform-issue 01a01e6a-3cb1).
7896
+ *
7897
+ * AI 스트리밍만 필요하다면 {@link isStreamReady} 를, 다운그레이드 여부를 알고 싶다면
7898
+ * {@link isDegraded} 를 쓴다.
7891
7899
  */
7892
7900
  isConnected() {
7901
+ return this.state === "connected" && this.activeTransport === "ws";
7902
+ }
7903
+ /**
7904
+ * SSE fallback 으로 다운그레이드된 상태인지 여부.
7905
+ *
7906
+ * true 면 연결은 살아 있지만 단방향이라 `realtime.stream()` 만 동작한다. 사용자에게
7907
+ * "제한된 네트워크라 일부 기능을 쓸 수 없다" 고 안내할 지점이다.
7908
+ */
7909
+ isDegraded() {
7910
+ return this.sseFallbackActive;
7911
+ }
7912
+ /**
7913
+ * AI 스트리밍(`realtime.stream()`) 사용 가능 여부.
7914
+ *
7915
+ * WS 든 SSE fallback 이든 연결만 서 있으면 true 다. 종전 `isConnected()` 와 같은
7916
+ * 판정이므로, 스트리밍 게이팅에 `isConnected()` 를 쓰던 코드는 이쪽으로 옮긴다.
7917
+ */
7918
+ isStreamReady() {
7893
7919
  return this.state === "connected";
7894
7920
  }
7895
7921
  /**
@@ -11020,7 +11046,7 @@ var VideoAPI = class {
11020
11046
  };
11021
11047
 
11022
11048
  // src/api/webrtc.ts
11023
- var WebRTCAPI = class {
11049
+ var WebRTCAPI = class _WebRTCAPI {
11024
11050
  constructor(http, webrtcUrl, appId) {
11025
11051
  this.ws = null;
11026
11052
  this.state = "disconnected";
@@ -11032,6 +11058,16 @@ var WebRTCAPI = class {
11032
11058
  this.reconnectAttempts = 0;
11033
11059
  this.maxReconnectAttempts = 5;
11034
11060
  this.reconnectTimeout = null;
11061
+ /**
11062
+ * 시그널링 소켓의 세대(generation) 토큰.
11063
+ *
11064
+ * 소켓을 열 때마다 1 증가하며, 각 핸들러는 자기가 열릴 때의 세대를 클로저에 담아둔다.
11065
+ * 세대가 밀린 핸들러는 "옛 소켓의 지연 이벤트" 이므로 인스턴스 상태를 건드리지 않는다.
11066
+ * 참조(`this.ws`)만 끊는 것으로는 부족하다 — 핸들러 클로저가 인스턴스를 캡처하고 있어
11067
+ * close 이벤트가 뒤늦게 도착하면 새 연결을 'disconnected' 로 덮어쓰고 새로 만든 피어
11068
+ * 연결까지 정리해버린다.
11069
+ */
11070
+ this.wsGeneration = 0;
11035
11071
  // 현재 연결 정보
11036
11072
  this.currentRoomId = null;
11037
11073
  this.currentPeerId = null;
@@ -11104,15 +11140,23 @@ var WebRTCAPI = class {
11104
11140
  connectWebSocket() {
11105
11141
  return new Promise((resolve, reject) => {
11106
11142
  const wsUrl = this.buildWebSocketUrl();
11107
- this.ws = new WebSocket(wsUrl);
11143
+ const generation = ++this.wsGeneration;
11144
+ const ws = new WebSocket(wsUrl);
11145
+ this.ws = ws;
11146
+ const isStale = () => this.wsGeneration !== generation;
11108
11147
  const timeout = setTimeout(() => {
11148
+ if (isStale()) return;
11109
11149
  if (this.state === "connecting") {
11110
- this.ws?.close();
11150
+ ws.close();
11111
11151
  reject(new Error("\uC5F0\uACB0 \uC2DC\uAC04 \uCD08\uACFC"));
11112
11152
  }
11113
11153
  }, 1e4);
11114
- this.ws.onopen = () => {
11154
+ ws.onopen = () => {
11115
11155
  clearTimeout(timeout);
11156
+ if (isStale()) {
11157
+ ws.close(1e3, "superseded");
11158
+ return;
11159
+ }
11116
11160
  this.reconnectAttempts = 0;
11117
11161
  this.sendSignaling({
11118
11162
  type: "join",
@@ -11123,7 +11167,8 @@ var WebRTCAPI = class {
11123
11167
  }
11124
11168
  });
11125
11169
  };
11126
- this.ws.onmessage = async (event) => {
11170
+ ws.onmessage = async (event) => {
11171
+ if (isStale()) return;
11127
11172
  try {
11128
11173
  const msg = JSON.parse(event.data);
11129
11174
  await this.handleSignalingMessage(msg, resolve, reject);
@@ -11131,13 +11176,15 @@ var WebRTCAPI = class {
11131
11176
  console.error("Failed to parse signaling message:", error);
11132
11177
  }
11133
11178
  };
11134
- this.ws.onerror = (event) => {
11179
+ ws.onerror = (event) => {
11135
11180
  clearTimeout(timeout);
11181
+ if (isStale()) return;
11136
11182
  console.error("WebSocket error:", event);
11137
11183
  this.emitError(new Error("WebSocket \uC5F0\uACB0 \uC624\uB958"));
11138
11184
  };
11139
- this.ws.onclose = (event) => {
11185
+ ws.onclose = (event) => {
11140
11186
  clearTimeout(timeout);
11187
+ if (isStale()) return;
11141
11188
  if (this.state === "connecting") {
11142
11189
  reject(new Error("\uC5F0\uACB0\uC774 \uC885\uB8CC\uB418\uC5C8\uC2B5\uB2C8\uB2E4"));
11143
11190
  }
@@ -11145,6 +11192,19 @@ var WebRTCAPI = class {
11145
11192
  };
11146
11193
  });
11147
11194
  }
11195
+ /**
11196
+ * 소켓에 붙은 핸들러를 전부 떼어낸다.
11197
+ *
11198
+ * `this.ws = null` 로 참조만 끊으면 클로저가 살아남아 close 이벤트가 계속 들어온다.
11199
+ * 세대 토큰이 그 이벤트를 무시해주긴 하지만, 핸들러 자체를 떼면 인스턴스 참조가 끊겨
11200
+ * 소켓이 GC 될 때까지 인스턴스를 붙잡고 있지 않는다.
11201
+ */
11202
+ static detachSocket(ws) {
11203
+ ws.onopen = null;
11204
+ ws.onmessage = null;
11205
+ ws.onerror = null;
11206
+ ws.onclose = null;
11207
+ }
11148
11208
  buildWebSocketUrl() {
11149
11209
  const wsBase = this.webrtcUrl.replace("https://", "wss://").replace("http://", "ws://");
11150
11210
  const publicKey = this.http.getPublicKey();
@@ -11355,19 +11415,76 @@ var WebRTCAPI = class {
11355
11415
  clearTimeout(this.reconnectTimeout);
11356
11416
  this.reconnectTimeout = null;
11357
11417
  }
11358
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
11359
- this.sendSignaling({ type: "leave" });
11360
- this.ws.close(1e3, "User disconnected");
11418
+ this.reconnectAttempts = 0;
11419
+ this.wsGeneration++;
11420
+ const ws = this.ws;
11421
+ this.ws = null;
11422
+ if (ws) {
11423
+ _WebRTCAPI.detachSocket(ws);
11424
+ if (ws.readyState === WebSocket.OPEN) {
11425
+ try {
11426
+ ws.send(JSON.stringify({ type: "leave" }));
11427
+ } catch {
11428
+ }
11429
+ ws.close(1e3, "User disconnected");
11430
+ } else if (ws.readyState === WebSocket.CONNECTING) {
11431
+ ws.close();
11432
+ }
11361
11433
  }
11362
11434
  this.peerConnections.forEach((pc) => pc.close());
11363
11435
  this.peerConnections.clear();
11364
11436
  this.remoteStreams.clear();
11365
- this.ws = null;
11366
11437
  this.currentRoomId = null;
11367
11438
  this.currentPeerId = null;
11368
11439
  this.localStream = null;
11369
11440
  this.setState("disconnected");
11370
11441
  }
11442
+ /**
11443
+ * 현재 세션의 룸을 교체한다.
11444
+ *
11445
+ * `disconnect()` 후 곧바로 `connect()` 하는 패턴을 안전하게 감싼 것이다. 옛 소켓의
11446
+ * close 이벤트가 새 연결의 'connecting' 구간에 떨어져도 세대 토큰이 걸러낸다.
11447
+ * 생략한 옵션은 현재 세션 값을 그대로 이어받는다.
11448
+ *
11449
+ * @example
11450
+ * ```typescript
11451
+ * await cb.webrtc.switchRoom('channel:room-b')
11452
+ * ```
11453
+ */
11454
+ async switchRoom(roomId, options = {}) {
11455
+ const localStream = options.localStream ?? this.localStream ?? void 0;
11456
+ const userId = options.userId ?? this.currentUserId ?? void 0;
11457
+ const isBroadcaster = options.isBroadcaster ?? this.isBroadcaster;
11458
+ this.disconnect();
11459
+ await this.connect({ roomId, userId, isBroadcaster, localStream });
11460
+ }
11461
+ /**
11462
+ * 소켓, 룸, 이벤트 리스너, 피어 연결을 **독립적으로** 갖는 새 WebRTC 세션을 만든다.
11463
+ *
11464
+ * `cb.webrtc` 는 인스턴스 하나에 소켓 하나/룸 하나라, 한 앱에 WebRTC 기능이 둘 이상이면
11465
+ * (예: 공간 음성채팅 + 1:1 통화) 서로 연결을 뺏고 이벤트가 교차한다. 기능마다 세션을
11466
+ * 하나씩 만들면 각자의 소켓과 핸들러를 갖는다.
11467
+ *
11468
+ * 인증 정보(HttpClient)와 서버 주소, appId 는 부모와 공유한다.
11469
+ *
11470
+ * @example
11471
+ * ```typescript
11472
+ * const voice = cb.webrtc.createSession()
11473
+ * const call = cb.webrtc.createSession()
11474
+ *
11475
+ * voice.onRemoteStream((peerId, stream) => attachToSpatialAudio(peerId, stream))
11476
+ * call.onRemoteStream((peerId, stream) => showCallScreen(stream))
11477
+ *
11478
+ * await voice.connect({ roomId: 'voice:lobby', localStream: mic })
11479
+ * await call.connect({ roomId: 'call:alice-bob', localStream: mic })
11480
+ *
11481
+ * // 각 세션은 독립적으로 끊는다
11482
+ * call.disconnect()
11483
+ * ```
11484
+ */
11485
+ createSession() {
11486
+ return new _WebRTCAPI(this.http, this.webrtcUrl, this.appId);
11487
+ }
11371
11488
  /**
11372
11489
  * 현재 연결 상태 조회
11373
11490
  */