connectbase-client 5.12.6 → 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.js CHANGED
@@ -5355,6 +5355,15 @@ var NativeAPI = class {
5355
5355
  return window.NativeBridge.clipboard.readImage();
5356
5356
  }
5357
5357
  return null;
5358
+ },
5359
+ /**
5360
+ * 클립보드에 특정 형식(MIME 타입, 예: 'text/plain', 'image/png')의 데이터가 있는지 확인 (데스크톱 전용)
5361
+ */
5362
+ has: async (format) => {
5363
+ if (window.NativeBridge?.clipboard?.has) {
5364
+ return window.NativeBridge.clipboard.has(format);
5365
+ }
5366
+ return false;
5358
5367
  }
5359
5368
  };
5360
5369
  /**
@@ -5393,25 +5402,33 @@ var NativeAPI = class {
5393
5402
  saveFile: async (content, filename, options) => {
5394
5403
  const platform = this.getPlatform();
5395
5404
  if (platform === "desktop" && window.NativeBridge?.filesystem) {
5396
- const result = await window.NativeBridge.filesystem.showSaveDialog?.({
5397
- defaultPath: filename,
5398
- filters: options?.filters
5399
- });
5400
- if (result?.canceled || !result?.filePath) return false;
5401
- const textContent = content instanceof Blob ? await content.text() : content;
5402
- const writeResult = await window.NativeBridge.filesystem.writeFile(
5403
- result.filePath,
5404
- textContent
5405
- );
5406
- return writeResult.success;
5405
+ try {
5406
+ const result = await window.NativeBridge.filesystem.showSaveDialog?.({
5407
+ defaultPath: filename,
5408
+ filters: options?.filters
5409
+ });
5410
+ if (result?.canceled || !result?.filePath) return false;
5411
+ const textContent = content instanceof Blob ? await content.text() : content;
5412
+ const writeResult = await window.NativeBridge.filesystem.writeFile(
5413
+ result.filePath,
5414
+ textContent
5415
+ );
5416
+ return writeResult.success;
5417
+ } catch {
5418
+ return false;
5419
+ }
5407
5420
  }
5408
5421
  if (platform === "mobile" && window.NativeBridge?.filesystem) {
5409
- const textContent = content instanceof Blob ? await content.text() : content;
5410
- const result = await window.NativeBridge.filesystem.writeFile(
5411
- filename,
5412
- textContent
5413
- );
5414
- return result.success;
5422
+ try {
5423
+ const textContent = content instanceof Blob ? await content.text() : content;
5424
+ const result = await window.NativeBridge.filesystem.writeFile(
5425
+ filename,
5426
+ textContent
5427
+ );
5428
+ return result.success;
5429
+ } catch {
5430
+ return false;
5431
+ }
5415
5432
  }
5416
5433
  const blob = content instanceof Blob ? content : new Blob([content], { type: "text/plain" });
5417
5434
  const url = URL.createObjectURL(blob);
@@ -5427,8 +5444,12 @@ var NativeAPI = class {
5427
5444
  */
5428
5445
  readFile: async (path) => {
5429
5446
  if (window.NativeBridge?.filesystem?.readFile) {
5430
- const result = await window.NativeBridge.filesystem.readFile(path);
5431
- return result.success ? result.content ?? null : null;
5447
+ try {
5448
+ const result = await window.NativeBridge.filesystem.readFile(path);
5449
+ return result.success ? result.content ?? null : null;
5450
+ } catch {
5451
+ return null;
5452
+ }
5432
5453
  }
5433
5454
  return null;
5434
5455
  },
@@ -5452,7 +5473,11 @@ var NativeAPI = class {
5452
5473
  takePicture: async (options) => {
5453
5474
  const platform = this.getPlatform();
5454
5475
  if (platform === "mobile" && window.NativeBridge?.camera) {
5455
- return window.NativeBridge.camera.takePicture(options);
5476
+ try {
5477
+ return await window.NativeBridge.camera.takePicture(options);
5478
+ } catch {
5479
+ return null;
5480
+ }
5456
5481
  }
5457
5482
  return new Promise((resolve) => {
5458
5483
  const input = document.createElement("input");
@@ -5489,7 +5514,11 @@ var NativeAPI = class {
5489
5514
  pickImage: async (options) => {
5490
5515
  const platform = this.getPlatform();
5491
5516
  if (platform === "mobile" && window.NativeBridge?.camera) {
5492
- return window.NativeBridge.camera.pickImage(options);
5517
+ try {
5518
+ return await window.NativeBridge.camera.pickImage(options);
5519
+ } catch {
5520
+ return null;
5521
+ }
5493
5522
  }
5494
5523
  return new Promise((resolve) => {
5495
5524
  const input = document.createElement("input");
@@ -5632,7 +5661,11 @@ var NativeAPI = class {
5632
5661
  */
5633
5662
  getToken: async () => {
5634
5663
  if (this.getPlatform() === "mobile" && window.NativeBridge?.push) {
5635
- return window.NativeBridge.push.getToken();
5664
+ try {
5665
+ return await window.NativeBridge.push.getToken();
5666
+ } catch {
5667
+ return null;
5668
+ }
5636
5669
  }
5637
5670
  return null;
5638
5671
  },
@@ -5643,8 +5676,12 @@ var NativeAPI = class {
5643
5676
  /** 로컬 알림 예약 (패키징 앱 전용) */
5644
5677
  scheduleLocal: async (options) => {
5645
5678
  if (this.getPlatform() === "mobile" && window.NativeBridge?.push) {
5646
- const result = await window.NativeBridge.push.scheduleLocal(options);
5647
- return result.notificationId;
5679
+ try {
5680
+ const result = await window.NativeBridge.push.scheduleLocal(options);
5681
+ return result.notificationId;
5682
+ } catch {
5683
+ return null;
5684
+ }
5648
5685
  }
5649
5686
  return null;
5650
5687
  },
@@ -5652,8 +5689,12 @@ var NativeAPI = class {
5652
5689
  setBadgeCount: async (count) => {
5653
5690
  const setter = window.NativeBridge?.push?.setBadgeCount;
5654
5691
  if (!setter) return false;
5655
- const result = await setter(count);
5656
- return result.success;
5692
+ try {
5693
+ const result = await setter(count);
5694
+ return result.success;
5695
+ } catch {
5696
+ return false;
5697
+ }
5657
5698
  }
5658
5699
  };
5659
5700
  /**
@@ -5672,12 +5713,12 @@ var NativeAPI = class {
5672
5713
  if (platform === "mobile" && window.NativeBridge?.push?.scheduleLocal) {
5673
5714
  const granted = await this.notification.requestPermission();
5674
5715
  if (!granted) return false;
5675
- await window.NativeBridge.push.scheduleLocal({
5716
+ const notificationId = await this.push.scheduleLocal({
5676
5717
  title: options.title,
5677
5718
  body: options.body,
5678
5719
  trigger: null
5679
5720
  });
5680
- return true;
5721
+ return notificationId !== null;
5681
5722
  }
5682
5723
  if (!("Notification" in window)) return false;
5683
5724
  if (Notification.permission !== "granted") {
@@ -5697,8 +5738,12 @@ var NativeAPI = class {
5697
5738
  requestPermission: async () => {
5698
5739
  const platform = this.getPlatform();
5699
5740
  if (platform === "mobile" && window.NativeBridge?.push) {
5700
- const result = await window.NativeBridge.push.requestPermission();
5701
- return result.granted;
5741
+ try {
5742
+ const result = await window.NativeBridge.push.requestPermission();
5743
+ return result.granted;
5744
+ } catch {
5745
+ return false;
5746
+ }
5702
5747
  }
5703
5748
  if (!("Notification" in window)) return false;
5704
5749
  const permission = await Notification.requestPermission();
@@ -5719,13 +5764,77 @@ var NativeAPI = class {
5719
5764
  return result.success;
5720
5765
  }
5721
5766
  if (platform === "mobile" && window.NativeBridge?.browser) {
5722
- const result = await window.NativeBridge.browser.openExternal(url);
5723
- return result.success;
5767
+ try {
5768
+ const result = await window.NativeBridge.browser.openExternal(url);
5769
+ return result.success;
5770
+ } catch {
5771
+ return false;
5772
+ }
5724
5773
  }
5725
5774
  window.open(url, "_blank");
5726
5775
  return true;
5727
5776
  }
5728
5777
  };
5778
+ /**
5779
+ * 모바일 전용: 네이티브 인앱 브라우저 세션으로 여는 OAuth
5780
+ *
5781
+ * WebView 안에서 소셜 로그인 팝업을 열면 세션/쿠키가 격리돼 실패하는 경우가 많다.
5782
+ * 패키징 앱(RN)에서는 시스템 브라우저 세션(iOS ASWebAuthenticationSession /
5783
+ * Android Custom Tabs)으로 열고 앱 URL 스킴 콜백으로 결과를 받는다.
5784
+ * 웹/데스크톱에서는 지원하지 않는다 — 일반 팝업/리다이렉트 OAuth 플로우를 쓸 것.
5785
+ *
5786
+ * @example
5787
+ * ```typescript
5788
+ * if (cb.native.getPlatform() === 'mobile') {
5789
+ * const result = await cb.native.oauth.signIn(authorizeUrl)
5790
+ * if (result) window.location.href = result.url // 콜백 URL 로 계속 진행
5791
+ * }
5792
+ * ```
5793
+ */
5794
+ this.oauth = {
5795
+ /**
5796
+ * OAuth 인가 URL 을 네이티브 인앱 브라우저 세션으로 열고 콜백 URL 을 기다린다.
5797
+ * callbackScheme 을 생략하면 이 패키징 앱의 스킴(getCallbackScheme())을 사용한다.
5798
+ * 사용자가 취소했거나 미지원 플랫폼이면 null.
5799
+ */
5800
+ signIn: async (authUrl, callbackScheme) => {
5801
+ const bridge = window.NativeBridge;
5802
+ if (this.getPlatform() !== "mobile" || !bridge?.oauth) return null;
5803
+ const scheme = callbackScheme ?? bridge.oauth.getCallbackScheme();
5804
+ try {
5805
+ return await bridge.oauth.signIn(authUrl, scheme);
5806
+ } catch {
5807
+ return null;
5808
+ }
5809
+ },
5810
+ /** 이 패키징 앱의 OAuth 콜백 URL 스킴(`<scheme>://oauth/callback`). 미지원 플랫폼은 null (동기) */
5811
+ getCallbackScheme: () => {
5812
+ if (this.getPlatform() !== "mobile" || !window.NativeBridge?.oauth) {
5813
+ return null;
5814
+ }
5815
+ return window.NativeBridge.oauth.getCallbackScheme();
5816
+ }
5817
+ };
5818
+ /**
5819
+ * 모바일 전용: 패키징 앱에 연결된 Connect Base Storage 로 파일 업로드.
5820
+ * 콘솔 패키징 설정에서 "파일 저장 위치" 를 Connect Base Storage 로 선택한 경우에만 지원한다.
5821
+ */
5822
+ this.connectbase = {
5823
+ uploadFile: async (uri, filename, mimeType) => {
5824
+ if (this.getPlatform() !== "mobile" || !window.NativeBridge?.connectbase) {
5825
+ return null;
5826
+ }
5827
+ try {
5828
+ return await window.NativeBridge.connectbase.uploadFile(
5829
+ uri,
5830
+ filename,
5831
+ mimeType
5832
+ );
5833
+ } catch {
5834
+ return null;
5835
+ }
5836
+ }
5837
+ };
5729
5838
  /**
5730
5839
  * 데스크톱 전용 창 제어 API
5731
5840
  */
@@ -5762,6 +5871,36 @@ var NativeAPI = class {
5762
5871
  await document.exitFullscreen();
5763
5872
  }
5764
5873
  }
5874
+ },
5875
+ isFullScreen: async () => {
5876
+ if (window.NativeBridge?.window) {
5877
+ return await window.NativeBridge.window.isFullScreen() ?? false;
5878
+ }
5879
+ return !!document.fullscreenElement;
5880
+ },
5881
+ /** 창 크기 설정 (데스크톱 전용) */
5882
+ setSize: async (width, height) => {
5883
+ await window.NativeBridge?.window?.setSize(width, height);
5884
+ },
5885
+ /** 창 크기 조회 (데스크톱 전용, 미지원 플랫폼은 null) */
5886
+ getSize: async () => {
5887
+ return await window.NativeBridge?.window?.getSize() ?? null;
5888
+ },
5889
+ /** 창 위치 설정 (데스크톱 전용) */
5890
+ setPosition: async (x, y) => {
5891
+ await window.NativeBridge?.window?.setPosition(x, y);
5892
+ },
5893
+ /** 창 위치 조회 (데스크톱 전용, 미지원 플랫폼은 null) */
5894
+ getPosition: async () => {
5895
+ return await window.NativeBridge?.window?.getPosition() ?? null;
5896
+ },
5897
+ /** 화면 중앙으로 이동 (데스크톱 전용) */
5898
+ center: async () => {
5899
+ await window.NativeBridge?.window?.center();
5900
+ },
5901
+ /** 항상 위에 표시 설정 (데스크톱 전용) */
5902
+ setAlwaysOnTop: async (flag) => {
5903
+ await window.NativeBridge?.window?.setAlwaysOnTop(flag);
5765
5904
  }
5766
5905
  };
5767
5906
  /**
@@ -5779,6 +5918,12 @@ var NativeAPI = class {
5779
5918
  return window.NativeBridge.system.getMemory();
5780
5919
  }
5781
5920
  return null;
5921
+ },
5922
+ getCPU: async () => {
5923
+ if (window.NativeBridge?.system) {
5924
+ return window.NativeBridge.system.getCPU();
5925
+ }
5926
+ return null;
5782
5927
  }
5783
5928
  };
5784
5929
  /**
@@ -5787,13 +5932,21 @@ var NativeAPI = class {
5787
5932
  this.biometric = {
5788
5933
  isAvailable: async () => {
5789
5934
  if (window.NativeBridge?.biometric) {
5790
- return window.NativeBridge.biometric.isAvailable();
5935
+ try {
5936
+ return await window.NativeBridge.biometric.isAvailable();
5937
+ } catch {
5938
+ return null;
5939
+ }
5791
5940
  }
5792
5941
  return null;
5793
5942
  },
5794
5943
  authenticate: async (options) => {
5795
5944
  if (window.NativeBridge?.biometric) {
5796
- return window.NativeBridge.biometric.authenticate(options);
5945
+ try {
5946
+ return await window.NativeBridge.biometric.authenticate(options);
5947
+ } catch {
5948
+ return null;
5949
+ }
5797
5950
  }
5798
5951
  return null;
5799
5952
  }
@@ -5804,26 +5957,38 @@ var NativeAPI = class {
5804
5957
  this.secureStore = {
5805
5958
  setItem: async (key, value) => {
5806
5959
  if (window.NativeBridge?.secureStore) {
5807
- const result = await window.NativeBridge.secureStore.setItem(
5808
- key,
5809
- value
5810
- );
5811
- return result.success;
5960
+ try {
5961
+ const result = await window.NativeBridge.secureStore.setItem(
5962
+ key,
5963
+ value
5964
+ );
5965
+ return result.success;
5966
+ } catch {
5967
+ return false;
5968
+ }
5812
5969
  }
5813
5970
  localStorage.setItem(key, value);
5814
5971
  return true;
5815
5972
  },
5816
5973
  getItem: async (key) => {
5817
5974
  if (window.NativeBridge?.secureStore) {
5818
- const result = await window.NativeBridge.secureStore.getItem(key);
5819
- return result.value;
5975
+ try {
5976
+ const result = await window.NativeBridge.secureStore.getItem(key);
5977
+ return result.value;
5978
+ } catch {
5979
+ return null;
5980
+ }
5820
5981
  }
5821
5982
  return localStorage.getItem(key);
5822
5983
  },
5823
5984
  deleteItem: async (key) => {
5824
5985
  if (window.NativeBridge?.secureStore) {
5825
- const result = await window.NativeBridge.secureStore.deleteItem(key);
5826
- return result.success;
5986
+ try {
5987
+ const result = await window.NativeBridge.secureStore.deleteItem(key);
5988
+ return result.success;
5989
+ } catch {
5990
+ return false;
5991
+ }
5827
5992
  }
5828
5993
  localStorage.removeItem(key);
5829
5994
  return true;
@@ -5835,15 +6000,23 @@ var NativeAPI = class {
5835
6000
  this.admob = {
5836
6001
  showInterstitial: async () => {
5837
6002
  if (window.NativeBridge?.admob) {
5838
- const result = await window.NativeBridge.admob.showInterstitial();
5839
- return result.shown;
6003
+ try {
6004
+ const result = await window.NativeBridge.admob.showInterstitial();
6005
+ return result.shown;
6006
+ } catch {
6007
+ return false;
6008
+ }
5840
6009
  }
5841
6010
  return false;
5842
6011
  },
5843
6012
  showRewarded: async () => {
5844
6013
  if (window.NativeBridge?.admob) {
5845
- const result = await window.NativeBridge.admob.showRewarded();
5846
- return result.rewarded;
6014
+ try {
6015
+ const result = await window.NativeBridge.admob.showRewarded();
6016
+ return result.rewarded;
6017
+ } catch {
6018
+ return false;
6019
+ }
5847
6020
  }
5848
6021
  return false;
5849
6022
  }
@@ -7763,9 +7936,35 @@ var RealtimeAPI = class {
7763
7936
  return this.state;
7764
7937
  }
7765
7938
  /**
7766
- * 연결 여부 확인
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} 를 쓴다.
7767
7948
  */
7768
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() {
7769
7968
  return this.state === "connected";
7770
7969
  }
7771
7970
  /**
@@ -10896,7 +11095,7 @@ var VideoAPI = class {
10896
11095
  };
10897
11096
 
10898
11097
  // src/api/webrtc.ts
10899
- var WebRTCAPI = class {
11098
+ var WebRTCAPI = class _WebRTCAPI {
10900
11099
  constructor(http, webrtcUrl, appId) {
10901
11100
  this.ws = null;
10902
11101
  this.state = "disconnected";
@@ -10908,6 +11107,16 @@ var WebRTCAPI = class {
10908
11107
  this.reconnectAttempts = 0;
10909
11108
  this.maxReconnectAttempts = 5;
10910
11109
  this.reconnectTimeout = null;
11110
+ /**
11111
+ * 시그널링 소켓의 세대(generation) 토큰.
11112
+ *
11113
+ * 소켓을 열 때마다 1 증가하며, 각 핸들러는 자기가 열릴 때의 세대를 클로저에 담아둔다.
11114
+ * 세대가 밀린 핸들러는 "옛 소켓의 지연 이벤트" 이므로 인스턴스 상태를 건드리지 않는다.
11115
+ * 참조(`this.ws`)만 끊는 것으로는 부족하다 — 핸들러 클로저가 인스턴스를 캡처하고 있어
11116
+ * close 이벤트가 뒤늦게 도착하면 새 연결을 'disconnected' 로 덮어쓰고 새로 만든 피어
11117
+ * 연결까지 정리해버린다.
11118
+ */
11119
+ this.wsGeneration = 0;
10911
11120
  // 현재 연결 정보
10912
11121
  this.currentRoomId = null;
10913
11122
  this.currentPeerId = null;
@@ -10980,15 +11189,23 @@ var WebRTCAPI = class {
10980
11189
  connectWebSocket() {
10981
11190
  return new Promise((resolve, reject) => {
10982
11191
  const wsUrl = this.buildWebSocketUrl();
10983
- 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;
10984
11196
  const timeout = setTimeout(() => {
11197
+ if (isStale()) return;
10985
11198
  if (this.state === "connecting") {
10986
- this.ws?.close();
11199
+ ws.close();
10987
11200
  reject(new Error("\uC5F0\uACB0 \uC2DC\uAC04 \uCD08\uACFC"));
10988
11201
  }
10989
11202
  }, 1e4);
10990
- this.ws.onopen = () => {
11203
+ ws.onopen = () => {
10991
11204
  clearTimeout(timeout);
11205
+ if (isStale()) {
11206
+ ws.close(1e3, "superseded");
11207
+ return;
11208
+ }
10992
11209
  this.reconnectAttempts = 0;
10993
11210
  this.sendSignaling({
10994
11211
  type: "join",
@@ -10999,7 +11216,8 @@ var WebRTCAPI = class {
10999
11216
  }
11000
11217
  });
11001
11218
  };
11002
- this.ws.onmessage = async (event) => {
11219
+ ws.onmessage = async (event) => {
11220
+ if (isStale()) return;
11003
11221
  try {
11004
11222
  const msg = JSON.parse(event.data);
11005
11223
  await this.handleSignalingMessage(msg, resolve, reject);
@@ -11007,13 +11225,15 @@ var WebRTCAPI = class {
11007
11225
  console.error("Failed to parse signaling message:", error);
11008
11226
  }
11009
11227
  };
11010
- this.ws.onerror = (event) => {
11228
+ ws.onerror = (event) => {
11011
11229
  clearTimeout(timeout);
11230
+ if (isStale()) return;
11012
11231
  console.error("WebSocket error:", event);
11013
11232
  this.emitError(new Error("WebSocket \uC5F0\uACB0 \uC624\uB958"));
11014
11233
  };
11015
- this.ws.onclose = (event) => {
11234
+ ws.onclose = (event) => {
11016
11235
  clearTimeout(timeout);
11236
+ if (isStale()) return;
11017
11237
  if (this.state === "connecting") {
11018
11238
  reject(new Error("\uC5F0\uACB0\uC774 \uC885\uB8CC\uB418\uC5C8\uC2B5\uB2C8\uB2E4"));
11019
11239
  }
@@ -11021,6 +11241,19 @@ var WebRTCAPI = class {
11021
11241
  };
11022
11242
  });
11023
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
+ }
11024
11257
  buildWebSocketUrl() {
11025
11258
  const wsBase = this.webrtcUrl.replace("https://", "wss://").replace("http://", "ws://");
11026
11259
  const publicKey = this.http.getPublicKey();
@@ -11231,19 +11464,76 @@ var WebRTCAPI = class {
11231
11464
  clearTimeout(this.reconnectTimeout);
11232
11465
  this.reconnectTimeout = null;
11233
11466
  }
11234
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
11235
- this.sendSignaling({ type: "leave" });
11236
- 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
+ }
11237
11482
  }
11238
11483
  this.peerConnections.forEach((pc) => pc.close());
11239
11484
  this.peerConnections.clear();
11240
11485
  this.remoteStreams.clear();
11241
- this.ws = null;
11242
11486
  this.currentRoomId = null;
11243
11487
  this.currentPeerId = null;
11244
11488
  this.localStream = null;
11245
11489
  this.setState("disconnected");
11246
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
+ }
11247
11537
  /**
11248
11538
  * 현재 연결 상태 조회
11249
11539
  */