connectbase-client 5.13.0 → 6.0.1
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/CHANGELOG.md +91 -0
- package/README.md +38 -0
- package/dist/connect-base.umd.js +5 -5
- package/dist/index.d.mts +92 -1
- package/dist/index.d.ts +92 -1
- package/dist/index.js +179 -12
- package/dist/index.mjs +179 -12
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
11150
|
+
ws.close();
|
|
11111
11151
|
reject(new Error("\uC5F0\uACB0 \uC2DC\uAC04 \uCD08\uACFC"));
|
|
11112
11152
|
}
|
|
11113
11153
|
}, 1e4);
|
|
11114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11359
|
-
|
|
11360
|
-
|
|
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
|
*/
|
|
@@ -11612,6 +11729,7 @@ function fetchCredentialsForPath(url) {
|
|
|
11612
11729
|
return path.startsWith("/v1/public/") || path.startsWith("/v1/proxy/") ? "omit" : "include";
|
|
11613
11730
|
}
|
|
11614
11731
|
var TOKEN_STORAGE_KEY = "cb_auth_tokens";
|
|
11732
|
+
var NO_COOKIE_SESSION_COOLDOWN_MS = 6e4;
|
|
11615
11733
|
function gatewayCodeFromStatus(status) {
|
|
11616
11734
|
switch (status) {
|
|
11617
11735
|
case 502:
|
|
@@ -11756,6 +11874,7 @@ var HttpClient = class {
|
|
|
11756
11874
|
this.config.refreshToken = refreshToken;
|
|
11757
11875
|
this.persistTokens();
|
|
11758
11876
|
this.markSessionHint();
|
|
11877
|
+
this.clearNoCookieSessionMark();
|
|
11759
11878
|
}
|
|
11760
11879
|
clearTokens() {
|
|
11761
11880
|
this.config.accessToken = void 0;
|
|
@@ -11799,6 +11918,45 @@ var HttpClient = class {
|
|
|
11799
11918
|
return true;
|
|
11800
11919
|
}
|
|
11801
11920
|
}
|
|
11921
|
+
// ===== cookie 세션 없음 마커 (platform-issue 01a0237f) =====
|
|
11922
|
+
//
|
|
11923
|
+
// `refreshLockedUntil` 백오프는 인스턴스 안에서만 산다. 앱이 클라이언트를 새로 만들 때마다
|
|
11924
|
+
// 초기화되므로, 쿠키가 없는 브라우저에서는 인증 호출마다 re-issue 401 이 새로 나간다.
|
|
11925
|
+
// 이 마커는 같은 사실을 localStorage 에 짧게 남겨 인스턴스 경계를 넘어 공유한다.
|
|
11926
|
+
buildNoCookieSessionKey() {
|
|
11927
|
+
return `${this.storageKey}:no_cookie_session`;
|
|
11928
|
+
}
|
|
11929
|
+
markNoCookieSession() {
|
|
11930
|
+
if (typeof window === "undefined") return;
|
|
11931
|
+
try {
|
|
11932
|
+
localStorage.setItem(this.buildNoCookieSessionKey(), String(Date.now()));
|
|
11933
|
+
} catch {
|
|
11934
|
+
}
|
|
11935
|
+
}
|
|
11936
|
+
clearNoCookieSessionMark() {
|
|
11937
|
+
if (typeof window === "undefined") return;
|
|
11938
|
+
try {
|
|
11939
|
+
localStorage.removeItem(this.buildNoCookieSessionKey());
|
|
11940
|
+
} catch {
|
|
11941
|
+
}
|
|
11942
|
+
}
|
|
11943
|
+
/** 쿠키 전용 refresh 를 지금 건너뛰어야 하는지 (최근에 401/403 을 받았는지). */
|
|
11944
|
+
isNoCookieSessionMarkFresh() {
|
|
11945
|
+
if (typeof window === "undefined") return false;
|
|
11946
|
+
try {
|
|
11947
|
+
const raw = localStorage.getItem(this.buildNoCookieSessionKey());
|
|
11948
|
+
if (!raw) return false;
|
|
11949
|
+
const at = Number.parseInt(raw, 10);
|
|
11950
|
+
if (!Number.isFinite(at)) return false;
|
|
11951
|
+
if (Date.now() - at >= NO_COOKIE_SESSION_COOLDOWN_MS) {
|
|
11952
|
+
this.clearNoCookieSessionMark();
|
|
11953
|
+
return false;
|
|
11954
|
+
}
|
|
11955
|
+
return true;
|
|
11956
|
+
} catch {
|
|
11957
|
+
return false;
|
|
11958
|
+
}
|
|
11959
|
+
}
|
|
11802
11960
|
/**
|
|
11803
11961
|
* OAuth redirect callback 직후 호출되어 HttpOnly cookie 를 부트스트랩한다.
|
|
11804
11962
|
*
|
|
@@ -11984,6 +12142,11 @@ var HttpClient = class {
|
|
|
11984
12142
|
this.config.onAuthError?.(error);
|
|
11985
12143
|
throw error;
|
|
11986
12144
|
}
|
|
12145
|
+
if (!this.config.refreshToken && this.isNoCookieSessionMarkFresh()) {
|
|
12146
|
+
throw new AuthError(
|
|
12147
|
+
"No cookie session (recently rejected). Skipping token refresh."
|
|
12148
|
+
);
|
|
12149
|
+
}
|
|
11987
12150
|
this.isRefreshing = true;
|
|
11988
12151
|
if (!this.config.refreshToken && typeof window === "undefined") {
|
|
11989
12152
|
this.isRefreshing = false;
|
|
@@ -12075,6 +12238,7 @@ var HttpClient = class {
|
|
|
12075
12238
|
});
|
|
12076
12239
|
this.refreshFailureCount = 0;
|
|
12077
12240
|
this.refreshLockedUntil = 0;
|
|
12241
|
+
this.clearNoCookieSessionMark();
|
|
12078
12242
|
return data.access_token;
|
|
12079
12243
|
} catch (e) {
|
|
12080
12244
|
const baseMsg = e instanceof Error ? e.message : "Token refresh failed";
|
|
@@ -12084,6 +12248,9 @@ var HttpClient = class {
|
|
|
12084
12248
|
3e4
|
|
12085
12249
|
);
|
|
12086
12250
|
this.refreshLockedUntil = Date.now() + backoffMs;
|
|
12251
|
+
if (failureKind === "permanent" && !this.config.refreshToken) {
|
|
12252
|
+
this.markNoCookieSession();
|
|
12253
|
+
}
|
|
12087
12254
|
if (failureKind === "permanent") {
|
|
12088
12255
|
if (!silent) {
|
|
12089
12256
|
this.clearTokens();
|