connectbase-client 6.4.0 → 6.6.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/CHANGELOG.md +106 -0
- package/README.md +62 -0
- package/dist/cli.js +1 -1
- package/dist/connect-base.umd.js +5 -5
- package/dist/index.d.mts +199 -1
- package/dist/index.d.ts +199 -1
- package/dist/index.js +410 -73
- package/dist/index.mjs +408 -73
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -36,6 +36,8 @@ __export(index_exports, {
|
|
|
36
36
|
GameRoomTransport: () => GameRoomTransport,
|
|
37
37
|
NativeAPI: () => NativeAPI,
|
|
38
38
|
OrganizationsAPI: () => OrganizationsAPI,
|
|
39
|
+
RealtimeConnectionError: () => RealtimeConnectionError,
|
|
40
|
+
RealtimeRequestError: () => RealtimeRequestError,
|
|
39
41
|
RolesAPI: () => RolesAPI,
|
|
40
42
|
SessionManager: () => SessionManager,
|
|
41
43
|
VideoProcessingError: () => VideoProcessingError,
|
|
@@ -282,6 +284,49 @@ var GameError = class extends Error {
|
|
|
282
284
|
};
|
|
283
285
|
}
|
|
284
286
|
};
|
|
287
|
+
var RealtimeConnectionError = class extends Error {
|
|
288
|
+
constructor(init) {
|
|
289
|
+
super(init.message || init.code || "Realtime connection rejected");
|
|
290
|
+
this.name = "RealtimeConnectionError";
|
|
291
|
+
this.code = init.code || "unknown";
|
|
292
|
+
this.closeCode = init.closeCode;
|
|
293
|
+
this.retryable = init.retryable ?? isRetryableRealtimeRejection(this.code);
|
|
294
|
+
this.details = init.details;
|
|
295
|
+
this.retryAfterMs = init.retryAfterMs;
|
|
296
|
+
}
|
|
297
|
+
/** `JSON.stringify(err)` 가 `{}` 가 되지 않도록 한다 (ApiError.toJSON 참조). */
|
|
298
|
+
toJSON() {
|
|
299
|
+
return {
|
|
300
|
+
name: this.name,
|
|
301
|
+
code: this.code,
|
|
302
|
+
message: this.message,
|
|
303
|
+
closeCode: this.closeCode,
|
|
304
|
+
retryable: this.retryable,
|
|
305
|
+
details: this.details,
|
|
306
|
+
retryAfterMs: this.retryAfterMs
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
function isRetryableRealtimeRejection(code) {
|
|
311
|
+
return code === "connection_limit_exceeded" || code === "server_at_capacity" || code === "rate_limited" || code === "auth_unavailable" || code === "internal_error";
|
|
312
|
+
}
|
|
313
|
+
var RealtimeRequestError = class extends Error {
|
|
314
|
+
constructor(init) {
|
|
315
|
+
super(init.message || init.code || "Realtime request failed");
|
|
316
|
+
this.name = "RealtimeRequestError";
|
|
317
|
+
this.code = init.code || "unknown";
|
|
318
|
+
this.category = init.category;
|
|
319
|
+
}
|
|
320
|
+
/** `JSON.stringify(err)` 가 `{}` 가 되지 않도록 한다 (ApiError.toJSON 참조). */
|
|
321
|
+
toJSON() {
|
|
322
|
+
return {
|
|
323
|
+
name: this.name,
|
|
324
|
+
code: this.code,
|
|
325
|
+
message: this.message,
|
|
326
|
+
category: this.category
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
};
|
|
285
330
|
|
|
286
331
|
// src/api/ai.ts
|
|
287
332
|
function aiErrorToApiError(err) {
|
|
@@ -7924,6 +7969,44 @@ var QueueAPI = class {
|
|
|
7924
7969
|
|
|
7925
7970
|
// src/api/realtime.ts
|
|
7926
7971
|
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7972
|
+
function toConnectionRejection(msg) {
|
|
7973
|
+
if (msg.event !== "error" || msg.request_id) return null;
|
|
7974
|
+
const data = msg.data;
|
|
7975
|
+
if (!data || typeof data.close_code !== "number") return null;
|
|
7976
|
+
return new RealtimeConnectionError({
|
|
7977
|
+
code: data.code,
|
|
7978
|
+
message: data.message || msg.error,
|
|
7979
|
+
closeCode: data.close_code,
|
|
7980
|
+
retryable: data.retryable,
|
|
7981
|
+
details: data.details,
|
|
7982
|
+
retryAfterMs: typeof data.retry_after_ms === "number" && data.retry_after_ms > 0 ? data.retry_after_ms : void 0
|
|
7983
|
+
});
|
|
7984
|
+
}
|
|
7985
|
+
var HandshakeFailedError = class extends Error {
|
|
7986
|
+
};
|
|
7987
|
+
var ConnectCancelledError = class extends Error {
|
|
7988
|
+
constructor() {
|
|
7989
|
+
super("Realtime connect cancelled by disconnect()");
|
|
7990
|
+
this.name = "AbortError";
|
|
7991
|
+
}
|
|
7992
|
+
};
|
|
7993
|
+
var MAX_HANDSHAKE_RETRIES = 2;
|
|
7994
|
+
function jitter(max) {
|
|
7995
|
+
return Math.floor(Math.random() * Math.max(0, max));
|
|
7996
|
+
}
|
|
7997
|
+
function toSendPolicy(value) {
|
|
7998
|
+
return value === "upstream" || value === "publishers_only" ? value : "open";
|
|
7999
|
+
}
|
|
8000
|
+
function toCategoryInfo(response) {
|
|
8001
|
+
return {
|
|
8002
|
+
category: response.category,
|
|
8003
|
+
persist: response.persist,
|
|
8004
|
+
historyCount: response.history_count,
|
|
8005
|
+
readReceipt: response.read_receipt,
|
|
8006
|
+
isPublisher: response.publisher === true,
|
|
8007
|
+
sendPolicy: toSendPolicy(response.send_policy)
|
|
8008
|
+
};
|
|
8009
|
+
}
|
|
7927
8010
|
var RealtimeAPI = class {
|
|
7928
8011
|
constructor(http, socketUrl) {
|
|
7929
8012
|
this.ws = null;
|
|
@@ -7951,6 +8034,28 @@ var RealtimeAPI = class {
|
|
|
7951
8034
|
fallback: "sse"
|
|
7952
8035
|
};
|
|
7953
8036
|
this.retryCount = 0;
|
|
8037
|
+
/**
|
|
8038
|
+
* 재연결 뒤 복구할 구독 상태. 연결이 끊긴 첫 순간에 떠 두고, 재연결이 성공해 복구한 뒤 비운다.
|
|
8039
|
+
*
|
|
8040
|
+
* 재연결 시도가 실패할 때마다 onclose → handleDisconnect 가 다시 불린다. 예전에는 그때마다 구독 맵을
|
|
8041
|
+
* 새로 떴는데, 첫 끊김에서 이미 비워진 맵이라 빈 스냅샷으로 덮였다 — 첫 재연결 시도가 실패하면
|
|
8042
|
+
* (서버 롤링 재시작 등) 재연결에 성공해도 구독이 하나도 복구되지 않았다.
|
|
8043
|
+
*/
|
|
8044
|
+
this.pendingRestore = null;
|
|
8045
|
+
/** 첫 연결 전에 direct 원본 힌트를 한 번 물었는지 (primeDirectRealtime). */
|
|
8046
|
+
this.directRealtimePrimed = false;
|
|
8047
|
+
/** disconnect() 마다 늘린다 — connect() 의 재시도 대기가 그 사이 취소됐는지 알아보는 데 쓴다. */
|
|
8048
|
+
this.connectGeneration = 0;
|
|
8049
|
+
/**
|
|
8050
|
+
* connect() 가 첫 연결을 시도하는 중인지. 이때의 핸드셰이크 실패는 connect() 가 직접 다시 시도하거나
|
|
8051
|
+
* 폴백하므로, onclose 가 백그라운드 재연결 체인(handleDisconnect)을 따로 시작하면 안 된다 — 두 경로가
|
|
8052
|
+
* 동시에 연결을 열게 된다.
|
|
8053
|
+
*/
|
|
8054
|
+
this.initialConnecting = false;
|
|
8055
|
+
/** 다음 재연결 대기의 하한(ms) — 서버가 알려 준 retry_after_ms. handleDisconnect 가 한 번 쓰고 비운다. */
|
|
8056
|
+
this.reconnectFloorMs = 0;
|
|
8057
|
+
/** connect() 의 재시도 대기를 끝내는 함수 — disconnect() 가 기다림 없이 바로 취소하게 한다. */
|
|
8058
|
+
this.cancelConnectWait = null;
|
|
7954
8059
|
this.pendingRequests = /* @__PURE__ */ new Map();
|
|
7955
8060
|
this.subscriptions = /* @__PURE__ */ new Map();
|
|
7956
8061
|
this.streamSessions = /* @__PURE__ */ new Map();
|
|
@@ -7994,7 +8099,7 @@ var RealtimeAPI = class {
|
|
|
7994
8099
|
if (this.state === "connected") {
|
|
7995
8100
|
return;
|
|
7996
8101
|
}
|
|
7997
|
-
if (this.
|
|
8102
|
+
if (this.connectPromise) {
|
|
7998
8103
|
return this.connectPromise;
|
|
7999
8104
|
}
|
|
8000
8105
|
this.options = { ...this.options, ...options };
|
|
@@ -8006,10 +8111,15 @@ var RealtimeAPI = class {
|
|
|
8006
8111
|
`Invalid userId: "${this.userId}" looks like a UUID, which is reserved for authenticated members. Pass that member's accessToken (JWT) instead of userId+publicKey, or use a non-UUID identifier for anonymous/guest connections.`
|
|
8007
8112
|
);
|
|
8008
8113
|
}
|
|
8009
|
-
this.connectPromise = this.
|
|
8114
|
+
this.connectPromise = this.primeDirectRealtime().then(
|
|
8115
|
+
() => this.connectWithRetry()
|
|
8116
|
+
);
|
|
8010
8117
|
try {
|
|
8011
8118
|
await this.connectPromise;
|
|
8012
8119
|
} catch (e) {
|
|
8120
|
+
if (e instanceof RealtimeConnectionError || e instanceof ConnectCancelledError) {
|
|
8121
|
+
throw e;
|
|
8122
|
+
}
|
|
8013
8123
|
const hasAuth = !!(this.options.accessToken || this.http.getPublicKey());
|
|
8014
8124
|
if (this.options.fallback !== "none" && hasAuth) {
|
|
8015
8125
|
this.activateSseFallback(e);
|
|
@@ -8038,6 +8148,7 @@ var RealtimeAPI = class {
|
|
|
8038
8148
|
this.activeTransport = "sse";
|
|
8039
8149
|
this.state = "connected";
|
|
8040
8150
|
this.retryCount = 0;
|
|
8151
|
+
this.pendingRestore = null;
|
|
8041
8152
|
this.log(
|
|
8042
8153
|
`WS unavailable (${causeMsg}); using SSE fallback transport for AI streaming`
|
|
8043
8154
|
);
|
|
@@ -8052,6 +8163,8 @@ var RealtimeAPI = class {
|
|
|
8052
8163
|
* 연결 해제
|
|
8053
8164
|
*/
|
|
8054
8165
|
disconnect() {
|
|
8166
|
+
this.connectGeneration++;
|
|
8167
|
+
this.cancelConnectWait?.();
|
|
8055
8168
|
this.state = "disconnected";
|
|
8056
8169
|
this.notifyStateChange();
|
|
8057
8170
|
if (this.ws) {
|
|
@@ -8094,6 +8207,7 @@ var RealtimeAPI = class {
|
|
|
8094
8207
|
this._appId = null;
|
|
8095
8208
|
this.retryCount = 0;
|
|
8096
8209
|
this.connectPromise = null;
|
|
8210
|
+
this.pendingRestore = null;
|
|
8097
8211
|
}
|
|
8098
8212
|
/**
|
|
8099
8213
|
* 카테고리 구독
|
|
@@ -8109,12 +8223,7 @@ var RealtimeAPI = class {
|
|
|
8109
8223
|
action: "subscribe",
|
|
8110
8224
|
request_id: requestId
|
|
8111
8225
|
});
|
|
8112
|
-
const info =
|
|
8113
|
-
category: response.category,
|
|
8114
|
-
persist: response.persist,
|
|
8115
|
-
historyCount: response.history_count,
|
|
8116
|
-
readReceipt: response.read_receipt
|
|
8117
|
-
};
|
|
8226
|
+
const info = toCategoryInfo(response);
|
|
8118
8227
|
const handlers = [];
|
|
8119
8228
|
this.subscriptions.set(category, { info, handlers });
|
|
8120
8229
|
const subscription = {
|
|
@@ -8170,7 +8279,7 @@ var RealtimeAPI = class {
|
|
|
8170
8279
|
await this.sendRequest({
|
|
8171
8280
|
category,
|
|
8172
8281
|
action: "send",
|
|
8173
|
-
data: { data, broadcast },
|
|
8282
|
+
data: options.to ? { data, broadcast, to: options.to } : { data, broadcast },
|
|
8174
8283
|
request_id: requestId
|
|
8175
8284
|
});
|
|
8176
8285
|
}
|
|
@@ -8876,7 +8985,92 @@ var RealtimeAPI = class {
|
|
|
8876
8985
|
};
|
|
8877
8986
|
}
|
|
8878
8987
|
// Private methods
|
|
8879
|
-
|
|
8988
|
+
/**
|
|
8989
|
+
* direct(저지연) 원본을 아직 모르면 첫 연결 전에 한 번 묻는다 — `GET /v1/public/edge-hints`.
|
|
8990
|
+
*
|
|
8991
|
+
* direct 원본은 core-server 응답 헤더(`X-CB-Realtime-Direct-Base`)로만 배운다. realtime 만 쓰는
|
|
8992
|
+
* 페이지는 WS 를 열기 전에 REST 를 부르지 않아 배울 기회가 없었고, 첫 연결이 늘 Cloudflare 를 타
|
|
8993
|
+
* 한국 사용자는 미국(LAX)을 거친 ~270ms 로 붙었다(platform-issue 01a15d9d 의 273ms). 응답 헤더는
|
|
8994
|
+
* HttpClient 가 평소처럼 학습한다. 광고 대상이 아니면(해외, 킬 스위치) 빈 응답이라 원래 소켓
|
|
8995
|
+
* URL 로 간다. 실패하거나 느리면(1.5초) 그냥 넘어간다 — 최적화일 뿐 연결의 전제가 아니다.
|
|
8996
|
+
* 세션당 한 번만 묻는다.
|
|
8997
|
+
*/
|
|
8998
|
+
async primeDirectRealtime() {
|
|
8999
|
+
if (this.directRealtimePrimed) return;
|
|
9000
|
+
this.directRealtimePrimed = true;
|
|
9001
|
+
if (this.http.getDirectRealtimeBaseUrl()) {
|
|
9002
|
+
return;
|
|
9003
|
+
}
|
|
9004
|
+
try {
|
|
9005
|
+
await this.http.primeEdgeHints(1500);
|
|
9006
|
+
} catch (e) {
|
|
9007
|
+
this.log(
|
|
9008
|
+
`edge hint lookup failed, using default socket URL: ${e instanceof Error ? e.message : String(e)}`
|
|
9009
|
+
);
|
|
9010
|
+
}
|
|
9011
|
+
}
|
|
9012
|
+
/**
|
|
9013
|
+
* 첫 연결을 연결 제한 시간(`timeout`) 안에서 다시 시도한다 (6.6.0+, platform-issue 01a15e24).
|
|
9014
|
+
*
|
|
9015
|
+
* - 서버가 재시도 시각을 알려 준 거부(`retryAfterMs`, 예: 한 IP 에서 연결이 몰린 `rate_limited`):
|
|
9016
|
+
* 그 시각에 지터를 더해 다시 붙는다. 공연 시작처럼 한꺼번에 들어올 때 앱이 재시도를 짜지 않아도 된다.
|
|
9017
|
+
* - 이유 없는 핸드셰이크 실패: 두 번까지 백오프 후 다시 한다. 네트워크가 WebSocket 을 실제로 막으면
|
|
9018
|
+
* 매번 실패하고, 앞단의 일시 거부는 다시 하면 붙는다 — 한 번 실패로 SSE 폴백하던 것이 연결 속도
|
|
9019
|
+
* 제한을 "네트워크 차단" 으로 오진한 원인이었다.
|
|
9020
|
+
* - 그 밖의 거부(자격증명, 동시접속 한도 등)와 시간 초과는 바로 던진다.
|
|
9021
|
+
*/
|
|
9022
|
+
async connectWithRetry() {
|
|
9023
|
+
const generation = this.connectGeneration;
|
|
9024
|
+
const deadline = Date.now() + this.options.timeout;
|
|
9025
|
+
let handshakeFailures = 0;
|
|
9026
|
+
this.initialConnecting = true;
|
|
9027
|
+
try {
|
|
9028
|
+
for (let attempt = 1; ; attempt++) {
|
|
9029
|
+
try {
|
|
9030
|
+
await this.doConnect(Math.max(deadline - Date.now(), 1));
|
|
9031
|
+
return;
|
|
9032
|
+
} catch (e) {
|
|
9033
|
+
let wait = null;
|
|
9034
|
+
if (e instanceof RealtimeConnectionError) {
|
|
9035
|
+
if (e.retryable && e.retryAfterMs !== void 0) {
|
|
9036
|
+
wait = e.retryAfterMs + jitter(Math.min(100 * 2 ** attempt, 3e3));
|
|
9037
|
+
} else if (e.code === "rate_limited") {
|
|
9038
|
+
const base = Math.min(500 * 2 ** (attempt - 1), 8e3);
|
|
9039
|
+
wait = base / 2 + jitter(base / 2);
|
|
9040
|
+
}
|
|
9041
|
+
} else if (e instanceof HandshakeFailedError && handshakeFailures < MAX_HANDSHAKE_RETRIES) {
|
|
9042
|
+
handshakeFailures++;
|
|
9043
|
+
const base = 500 * 2 ** (handshakeFailures - 1);
|
|
9044
|
+
wait = base / 2 + jitter(base / 2);
|
|
9045
|
+
}
|
|
9046
|
+
if (wait === null || Date.now() + wait >= deadline) {
|
|
9047
|
+
if (this.state === "connecting") {
|
|
9048
|
+
this.state = "disconnected";
|
|
9049
|
+
this.notifyStateChange();
|
|
9050
|
+
}
|
|
9051
|
+
throw e;
|
|
9052
|
+
}
|
|
9053
|
+
this.log(
|
|
9054
|
+
`Connect attempt ${attempt} failed (${e instanceof Error ? e.message : String(e)}); retrying in ${wait}ms`
|
|
9055
|
+
);
|
|
9056
|
+
await new Promise((resolve) => {
|
|
9057
|
+
const timer = setTimeout(resolve, wait);
|
|
9058
|
+
this.cancelConnectWait = () => {
|
|
9059
|
+
clearTimeout(timer);
|
|
9060
|
+
resolve();
|
|
9061
|
+
};
|
|
9062
|
+
});
|
|
9063
|
+
this.cancelConnectWait = null;
|
|
9064
|
+
if (generation !== this.connectGeneration) {
|
|
9065
|
+
throw new ConnectCancelledError();
|
|
9066
|
+
}
|
|
9067
|
+
}
|
|
9068
|
+
}
|
|
9069
|
+
} finally {
|
|
9070
|
+
this.initialConnecting = false;
|
|
9071
|
+
}
|
|
9072
|
+
}
|
|
9073
|
+
async doConnect(timeoutMs = this.options.timeout) {
|
|
8880
9074
|
return new Promise((resolve, reject) => {
|
|
8881
9075
|
this.state = "connecting";
|
|
8882
9076
|
this.notifyStateChange();
|
|
@@ -8904,7 +9098,9 @@ var RealtimeAPI = class {
|
|
|
8904
9098
|
if (this.userId) {
|
|
8905
9099
|
url += `&user_id=${encodeURIComponent(this.userId)}`;
|
|
8906
9100
|
}
|
|
9101
|
+
url += "&reject_mode=close";
|
|
8907
9102
|
let settled = false;
|
|
9103
|
+
let rejection = null;
|
|
8908
9104
|
const timeoutId = setTimeout(() => {
|
|
8909
9105
|
if (!settled) {
|
|
8910
9106
|
settled = true;
|
|
@@ -8920,7 +9116,7 @@ var RealtimeAPI = class {
|
|
|
8920
9116
|
new Error(`Connection timeout after ${this.options.timeout}ms`)
|
|
8921
9117
|
);
|
|
8922
9118
|
}
|
|
8923
|
-
},
|
|
9119
|
+
}, timeoutMs);
|
|
8924
9120
|
try {
|
|
8925
9121
|
this.log(`Connecting to ${wsUrl}`);
|
|
8926
9122
|
this.ws = new WebSocket(url);
|
|
@@ -8932,6 +9128,11 @@ var RealtimeAPI = class {
|
|
|
8932
9128
|
for (const line of messages) {
|
|
8933
9129
|
try {
|
|
8934
9130
|
const msg = JSON.parse(line);
|
|
9131
|
+
const rejected = toConnectionRejection(msg);
|
|
9132
|
+
if (rejected && !settled) {
|
|
9133
|
+
rejection = rejected;
|
|
9134
|
+
continue;
|
|
9135
|
+
}
|
|
8935
9136
|
this.handleServerMessage(msg, () => {
|
|
8936
9137
|
if (!settled) {
|
|
8937
9138
|
settled = true;
|
|
@@ -8949,16 +9150,45 @@ var RealtimeAPI = class {
|
|
|
8949
9150
|
this.log(
|
|
8950
9151
|
`WebSocket closed: code=${event.code}, reason=${event.reason}`
|
|
8951
9152
|
);
|
|
9153
|
+
if (event.code >= 4e3 && event.code <= 4999) {
|
|
9154
|
+
const err = rejection ?? new RealtimeConnectionError({
|
|
9155
|
+
code: event.reason || void 0,
|
|
9156
|
+
closeCode: event.code
|
|
9157
|
+
});
|
|
9158
|
+
const wasConnected = this.state === "connected";
|
|
9159
|
+
if (!settled) {
|
|
9160
|
+
settled = true;
|
|
9161
|
+
clearTimeout(timeoutId);
|
|
9162
|
+
}
|
|
9163
|
+
this.ws = null;
|
|
9164
|
+
if (wasConnected) {
|
|
9165
|
+
if (err.retryable) {
|
|
9166
|
+
this.handleDisconnect();
|
|
9167
|
+
} else {
|
|
9168
|
+
this.failRealtime(err);
|
|
9169
|
+
}
|
|
9170
|
+
return;
|
|
9171
|
+
}
|
|
9172
|
+
this.state = "disconnected";
|
|
9173
|
+
this.notifyStateChange();
|
|
9174
|
+
reject(err);
|
|
9175
|
+
return;
|
|
9176
|
+
}
|
|
8952
9177
|
if (!settled && this.state === "connecting") {
|
|
8953
9178
|
settled = true;
|
|
8954
9179
|
clearTimeout(timeoutId);
|
|
8955
9180
|
if (usingDirect) this.http.disableDirectRealtime();
|
|
8956
9181
|
reject(
|
|
8957
|
-
new
|
|
9182
|
+
new HandshakeFailedError(
|
|
8958
9183
|
`Connection closed: ${event.reason || "unknown reason"}`
|
|
8959
9184
|
)
|
|
8960
9185
|
);
|
|
8961
9186
|
}
|
|
9187
|
+
if (this.initialConnecting && this.state === "connecting") {
|
|
9188
|
+
this.state = "disconnected";
|
|
9189
|
+
this.ws = null;
|
|
9190
|
+
return;
|
|
9191
|
+
}
|
|
8962
9192
|
if (this.state === "connected" || this.state === "connecting") {
|
|
8963
9193
|
this.handleDisconnect();
|
|
8964
9194
|
}
|
|
@@ -8966,12 +9196,14 @@ var RealtimeAPI = class {
|
|
|
8966
9196
|
this.ws.onerror = (error) => {
|
|
8967
9197
|
this.log("WebSocket error occurred");
|
|
8968
9198
|
this.logError("WebSocket error", error);
|
|
8969
|
-
this.
|
|
9199
|
+
if (!this.initialConnecting) {
|
|
9200
|
+
this.notifyError(new Error("WebSocket connection error"));
|
|
9201
|
+
}
|
|
8970
9202
|
if (!settled && this.state === "connecting") {
|
|
8971
9203
|
settled = true;
|
|
8972
9204
|
clearTimeout(timeoutId);
|
|
8973
9205
|
if (usingDirect) this.http.disableDirectRealtime();
|
|
8974
|
-
reject(new
|
|
9206
|
+
reject(new HandshakeFailedError("Failed to connect"));
|
|
8975
9207
|
}
|
|
8976
9208
|
};
|
|
8977
9209
|
} catch (e) {
|
|
@@ -9035,7 +9267,8 @@ var RealtimeAPI = class {
|
|
|
9035
9267
|
category: data.category,
|
|
9036
9268
|
from: data.from,
|
|
9037
9269
|
data: data.data,
|
|
9038
|
-
sentAt: data.sent_at
|
|
9270
|
+
sentAt: data.sent_at,
|
|
9271
|
+
fromPublisher: data.from_publisher === true
|
|
9039
9272
|
};
|
|
9040
9273
|
sub.handlers.forEach((h) => h(message));
|
|
9041
9274
|
}
|
|
@@ -9046,7 +9279,14 @@ var RealtimeAPI = class {
|
|
|
9046
9279
|
const pending = this.pendingRequests.get(msg.request_id);
|
|
9047
9280
|
if (pending) {
|
|
9048
9281
|
clearTimeout(pending.timeout);
|
|
9049
|
-
|
|
9282
|
+
const errData = msg.data;
|
|
9283
|
+
pending.reject(
|
|
9284
|
+
new RealtimeRequestError({
|
|
9285
|
+
code: errData?.code,
|
|
9286
|
+
message: msg.error || "Unknown error",
|
|
9287
|
+
category: msg.category
|
|
9288
|
+
})
|
|
9289
|
+
);
|
|
9050
9290
|
this.pendingRequests.delete(msg.request_id);
|
|
9051
9291
|
}
|
|
9052
9292
|
} else {
|
|
@@ -9195,41 +9435,66 @@ var RealtimeAPI = class {
|
|
|
9195
9435
|
}
|
|
9196
9436
|
});
|
|
9197
9437
|
this.streamSessions.clear();
|
|
9198
|
-
|
|
9199
|
-
|
|
9200
|
-
|
|
9438
|
+
if (!this.pendingRestore) {
|
|
9439
|
+
const subscriptions = /* @__PURE__ */ new Map();
|
|
9440
|
+
const infos = /* @__PURE__ */ new Map();
|
|
9441
|
+
for (const [category, sub] of this.subscriptions) {
|
|
9442
|
+
subscriptions.set(category, [...sub.handlers]);
|
|
9443
|
+
infos.set(category, sub.info);
|
|
9444
|
+
}
|
|
9445
|
+
const presence = /* @__PURE__ */ new Map();
|
|
9446
|
+
for (const [userId, handlers] of this.presenceSubscriptions) {
|
|
9447
|
+
presence.set(userId, [...handlers]);
|
|
9448
|
+
}
|
|
9449
|
+
const typing = /* @__PURE__ */ new Map();
|
|
9450
|
+
for (const [roomId, handlers] of this.typingHandlers) {
|
|
9451
|
+
typing.set(roomId, [...handlers]);
|
|
9452
|
+
}
|
|
9453
|
+
this.pendingRestore = { subscriptions, infos, presence, typing };
|
|
9201
9454
|
}
|
|
9202
9455
|
this.subscriptions.clear();
|
|
9203
|
-
const previousPresenceSubscriptions = /* @__PURE__ */ new Map();
|
|
9204
|
-
for (const [userId, handlers] of this.presenceSubscriptions) {
|
|
9205
|
-
previousPresenceSubscriptions.set(userId, [...handlers]);
|
|
9206
|
-
}
|
|
9207
9456
|
this.presenceSubscriptions.clear();
|
|
9208
|
-
const previousTypingHandlers = /* @__PURE__ */ new Map();
|
|
9209
|
-
for (const [roomId, handlers] of this.typingHandlers) {
|
|
9210
|
-
previousTypingHandlers.set(roomId, [...handlers]);
|
|
9211
|
-
}
|
|
9212
9457
|
this.typingHandlers.clear();
|
|
9213
9458
|
if (this.retryCount < this.options.maxRetries) {
|
|
9214
9459
|
this.state = "reconnecting";
|
|
9215
9460
|
this.notifyStateChange();
|
|
9216
9461
|
this.retryCount++;
|
|
9217
|
-
const
|
|
9462
|
+
const base = Math.min(
|
|
9218
9463
|
this.options.retryInterval * 2 ** (this.retryCount - 1),
|
|
9219
9464
|
3e4
|
|
9220
9465
|
);
|
|
9466
|
+
const delay = Math.max(
|
|
9467
|
+
Math.ceil(base / 2) + jitter(Math.floor(base / 2)),
|
|
9468
|
+
this.reconnectFloorMs
|
|
9469
|
+
);
|
|
9470
|
+
this.reconnectFloorMs = 0;
|
|
9221
9471
|
setTimeout(async () => {
|
|
9222
9472
|
if (this.sseFallbackActive) return;
|
|
9473
|
+
if (this.state !== "reconnecting") return;
|
|
9223
9474
|
try {
|
|
9224
9475
|
await this.doConnect();
|
|
9225
9476
|
this.log("Reconnected successfully, restoring subscriptions...");
|
|
9226
|
-
|
|
9227
|
-
|
|
9228
|
-
|
|
9229
|
-
|
|
9230
|
-
|
|
9477
|
+
const snapshot = this.pendingRestore;
|
|
9478
|
+
this.pendingRestore = null;
|
|
9479
|
+
if (snapshot) {
|
|
9480
|
+
await this.restoreSubscriptions(
|
|
9481
|
+
snapshot.subscriptions,
|
|
9482
|
+
snapshot.presence,
|
|
9483
|
+
snapshot.typing,
|
|
9484
|
+
snapshot.infos
|
|
9485
|
+
);
|
|
9486
|
+
}
|
|
9231
9487
|
} catch (e) {
|
|
9232
9488
|
this.logError("Reconnect failed", e);
|
|
9489
|
+
if (e instanceof RealtimeConnectionError) {
|
|
9490
|
+
if (e.retryable && this.retryCount < this.options.maxRetries) {
|
|
9491
|
+
this.reconnectFloorMs = e.retryAfterMs ?? 0;
|
|
9492
|
+
this.handleDisconnect();
|
|
9493
|
+
} else {
|
|
9494
|
+
this.failRealtime(e);
|
|
9495
|
+
}
|
|
9496
|
+
return;
|
|
9497
|
+
}
|
|
9233
9498
|
if (this.retryCount >= this.options.maxRetries && this.options.fallback !== "none" && !this.sseFallbackActive) {
|
|
9234
9499
|
this.activateSseFallback(e);
|
|
9235
9500
|
}
|
|
@@ -9247,11 +9512,23 @@ var RealtimeAPI = class {
|
|
|
9247
9512
|
}
|
|
9248
9513
|
}
|
|
9249
9514
|
}
|
|
9515
|
+
/**
|
|
9516
|
+
* 재연결을 멈추고 앱에 알린다 — 서버가 재시도해도 소용없는 이유(자격증명, 할당량)로 거부했거나
|
|
9517
|
+
* 재시도할 수 있는 거부가 maxRetries 를 다 썼다.
|
|
9518
|
+
*/
|
|
9519
|
+
failRealtime(err) {
|
|
9520
|
+
this.pendingRestore = null;
|
|
9521
|
+
this.ws = null;
|
|
9522
|
+
this._connectionId = null;
|
|
9523
|
+
this.state = "disconnected";
|
|
9524
|
+
this.notifyStateChange();
|
|
9525
|
+
this.notifyError(err);
|
|
9526
|
+
}
|
|
9250
9527
|
/**
|
|
9251
9528
|
* 이전 구독 상태를 복구합니다.
|
|
9252
9529
|
* 재연결 성공 후 호출됩니다.
|
|
9253
9530
|
*/
|
|
9254
|
-
async restoreSubscriptions(previousSubscriptions, previousPresenceSubscriptions, previousTypingHandlers) {
|
|
9531
|
+
async restoreSubscriptions(previousSubscriptions, previousPresenceSubscriptions, previousTypingHandlers, previousInfos) {
|
|
9255
9532
|
for (const [category, handlers] of previousSubscriptions) {
|
|
9256
9533
|
try {
|
|
9257
9534
|
this.log(`Restoring subscription: ${category}`);
|
|
@@ -9261,12 +9538,9 @@ var RealtimeAPI = class {
|
|
|
9261
9538
|
action: "subscribe",
|
|
9262
9539
|
request_id: requestId
|
|
9263
9540
|
});
|
|
9264
|
-
const
|
|
9265
|
-
|
|
9266
|
-
|
|
9267
|
-
historyCount: response.history_count,
|
|
9268
|
-
readReceipt: response.read_receipt
|
|
9269
|
-
};
|
|
9541
|
+
const fresh = toCategoryInfo(response);
|
|
9542
|
+
const info = previousInfos?.get(category) ?? fresh;
|
|
9543
|
+
Object.assign(info, fresh);
|
|
9270
9544
|
this.subscriptions.set(category, { info, handlers });
|
|
9271
9545
|
this.log(`Restored subscription: ${category}`);
|
|
9272
9546
|
} catch (e) {
|
|
@@ -9319,7 +9593,7 @@ var RealtimeAPI = class {
|
|
|
9319
9593
|
assertBidirectional(feature) {
|
|
9320
9594
|
if (this.activeTransport === "sse") {
|
|
9321
9595
|
throw new Error(
|
|
9322
|
-
`realtime.${feature}() requires a WebSocket connection, which
|
|
9596
|
+
`realtime.${feature}() requires a WebSocket connection, which could not be established (the network may block WebSocket, or the connection was repeatedly refused). Only AI streaming (realtime.stream) works in SSE fallback mode (cb.realtime.transport === 'sse').`
|
|
9323
9597
|
);
|
|
9324
9598
|
}
|
|
9325
9599
|
}
|
|
@@ -9473,6 +9747,28 @@ function createTimeoutController(options = {}) {
|
|
|
9473
9747
|
}
|
|
9474
9748
|
};
|
|
9475
9749
|
}
|
|
9750
|
+
function withAbort(promise, signal) {
|
|
9751
|
+
if (!signal) return promise;
|
|
9752
|
+
const reason = () => signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
|
|
9753
|
+
if (signal.aborted) {
|
|
9754
|
+
promise.catch(() => void 0);
|
|
9755
|
+
return Promise.reject(reason());
|
|
9756
|
+
}
|
|
9757
|
+
return new Promise((resolve, reject) => {
|
|
9758
|
+
const onAbort = () => reject(reason());
|
|
9759
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
9760
|
+
promise.then(
|
|
9761
|
+
(value) => {
|
|
9762
|
+
signal.removeEventListener("abort", onAbort);
|
|
9763
|
+
resolve(value);
|
|
9764
|
+
},
|
|
9765
|
+
(error) => {
|
|
9766
|
+
signal.removeEventListener("abort", onAbort);
|
|
9767
|
+
reject(error);
|
|
9768
|
+
}
|
|
9769
|
+
);
|
|
9770
|
+
});
|
|
9771
|
+
}
|
|
9476
9772
|
|
|
9477
9773
|
// src/core/url-validation.ts
|
|
9478
9774
|
var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
|
|
@@ -12840,14 +13136,14 @@ var HttpClient = class {
|
|
|
12840
13136
|
if (credential) {
|
|
12841
13137
|
headers["X-Public-Key"] = credential;
|
|
12842
13138
|
}
|
|
12843
|
-
const response = await
|
|
12844
|
-
`${this.config.baseUrl}/v1/auth/re-issue`,
|
|
12845
|
-
{
|
|
13139
|
+
const response = await withAbort(
|
|
13140
|
+
fetch(`${this.config.baseUrl}/v1/auth/re-issue`, {
|
|
12846
13141
|
method: "POST",
|
|
12847
13142
|
headers,
|
|
12848
13143
|
credentials: "include",
|
|
12849
13144
|
signal
|
|
12850
|
-
}
|
|
13145
|
+
}),
|
|
13146
|
+
signal
|
|
12851
13147
|
);
|
|
12852
13148
|
if (!response.ok) {
|
|
12853
13149
|
const status = response.status;
|
|
@@ -13184,38 +13480,71 @@ var HttpClient = class {
|
|
|
13184
13480
|
*/
|
|
13185
13481
|
learnDirectOrigin(response) {
|
|
13186
13482
|
if (!response.headers) return;
|
|
13483
|
+
this.adoptDirectBases(
|
|
13484
|
+
response.headers.get("X-CB-Direct-Base"),
|
|
13485
|
+
response.headers.get("X-CB-Realtime-Direct-Base")
|
|
13486
|
+
);
|
|
13487
|
+
}
|
|
13488
|
+
/**
|
|
13489
|
+
* 광고된 direct 원본을 검증해 받아들인다. 응답 헤더(learnDirectOrigin)와 edge-hints 본문
|
|
13490
|
+
* (primeEdgeHints)이 같은 검증을 거친다.
|
|
13491
|
+
*/
|
|
13492
|
+
adoptDirectBases(advertised, advertisedRealtime) {
|
|
13187
13493
|
const tail = (h) => h.split(".").slice(-2).join(".");
|
|
13188
13494
|
let base = null;
|
|
13189
13495
|
try {
|
|
13190
13496
|
base = new URL(this.config.baseUrl);
|
|
13191
13497
|
} catch {
|
|
13192
13498
|
}
|
|
13193
|
-
if (base && !this.directDisabled && !this.directBaseUrl) {
|
|
13194
|
-
|
|
13195
|
-
|
|
13196
|
-
|
|
13197
|
-
|
|
13198
|
-
if (next.protocol === "https:" && tail(next.hostname) === tail(base.hostname)) {
|
|
13199
|
-
this.directBaseUrl = next.origin;
|
|
13200
|
-
}
|
|
13201
|
-
} catch {
|
|
13499
|
+
if (base && !this.directDisabled && !this.directBaseUrl && typeof advertised === "string" && advertised) {
|
|
13500
|
+
try {
|
|
13501
|
+
const next = new URL(advertised);
|
|
13502
|
+
if (next.protocol === "https:" && tail(next.hostname) === tail(base.hostname)) {
|
|
13503
|
+
this.directBaseUrl = next.origin;
|
|
13202
13504
|
}
|
|
13505
|
+
} catch {
|
|
13203
13506
|
}
|
|
13204
13507
|
}
|
|
13205
|
-
if (!this.directRealtimeDisabled && !this.directRealtimeBaseUrl) {
|
|
13508
|
+
if (!this.directRealtimeDisabled && !this.directRealtimeBaseUrl && typeof advertisedRealtime === "string" && advertisedRealtime) {
|
|
13206
13509
|
const expectedTail = base ? tail(base.hostname) : "connectbase.world";
|
|
13207
|
-
|
|
13208
|
-
|
|
13209
|
-
|
|
13210
|
-
|
|
13211
|
-
try {
|
|
13212
|
-
const next = new URL(advertisedRealtime);
|
|
13213
|
-
if (next.protocol === "wss:" && tail(next.hostname) === expectedTail) {
|
|
13214
|
-
this.directRealtimeBaseUrl = next.origin;
|
|
13215
|
-
}
|
|
13216
|
-
} catch {
|
|
13510
|
+
try {
|
|
13511
|
+
const next = new URL(advertisedRealtime);
|
|
13512
|
+
if (next.protocol === "wss:" && tail(next.hostname) === expectedTail) {
|
|
13513
|
+
this.directRealtimeBaseUrl = next.origin;
|
|
13217
13514
|
}
|
|
13515
|
+
} catch {
|
|
13516
|
+
}
|
|
13517
|
+
}
|
|
13518
|
+
}
|
|
13519
|
+
/**
|
|
13520
|
+
* realtime 첫 연결 전에 저지연 direct 원본 힌트를 묻는다 (`GET /v1/public/edge-hints`, 6.6.0+).
|
|
13521
|
+
*
|
|
13522
|
+
* **커스텀 헤더 없는 단순 GET** 이다. `X-Public-Key` 같은 헤더가 붙으면 브라우저가 CORS 사전 요청을
|
|
13523
|
+
* 먼저 보내 Cloudflare 를 두 번 왕복하고, 앱의 "로컬 개발 origin 허용" 이 꺼진 localhost 페이지에서는
|
|
13524
|
+
* 키 기반 opt-in 에 막혀 브라우저만 direct 를 못 썼다(Node 는 썼다 — platform-issue 01a15e24).
|
|
13525
|
+
* 서버는 이 경로를 키 없이 열어 두고 같은 값을 본문으로도 준다. 본문을 읽으므로 CORS
|
|
13526
|
+
* Expose-Headers 에도 기대지 않는다. 실패는 조용히 무시한다 — direct 는 최적화일 뿐이다.
|
|
13527
|
+
*/
|
|
13528
|
+
async primeEdgeHints(timeoutMs) {
|
|
13529
|
+
if (this.directRealtimeBaseUrl || this.directRealtimeDisabled) return;
|
|
13530
|
+
const { signal, cleanup } = createTimeoutController({ timeout: timeoutMs });
|
|
13531
|
+
try {
|
|
13532
|
+
const response = await withAbort(
|
|
13533
|
+
fetch(`${this.config.baseUrl}/v1/public/edge-hints`, {
|
|
13534
|
+
method: "GET",
|
|
13535
|
+
credentials: "omit",
|
|
13536
|
+
signal
|
|
13537
|
+
}),
|
|
13538
|
+
signal
|
|
13539
|
+
);
|
|
13540
|
+
if (!response.ok) {
|
|
13541
|
+
throw new Error(`edge-hints HTTP ${response.status}`);
|
|
13218
13542
|
}
|
|
13543
|
+
this.learnDirectOrigin(response);
|
|
13544
|
+
const body = await withAbort(response.json(), signal);
|
|
13545
|
+
this.adoptDirectBases(body?.direct_base, body?.realtime_direct_base);
|
|
13546
|
+
} finally {
|
|
13547
|
+
cleanup();
|
|
13219
13548
|
}
|
|
13220
13549
|
}
|
|
13221
13550
|
/**
|
|
@@ -13249,11 +13578,14 @@ var HttpClient = class {
|
|
|
13249
13578
|
});
|
|
13250
13579
|
const origin = this.resolveOrigin(url, config?.baseUrl);
|
|
13251
13580
|
try {
|
|
13252
|
-
const response = await
|
|
13253
|
-
|
|
13254
|
-
|
|
13581
|
+
const response = await withAbort(
|
|
13582
|
+
fetch(`${origin}${url}`, {
|
|
13583
|
+
...init,
|
|
13584
|
+
credentials: fetchCredentialsForPath(url),
|
|
13585
|
+
signal
|
|
13586
|
+
}),
|
|
13255
13587
|
signal
|
|
13256
|
-
|
|
13588
|
+
);
|
|
13257
13589
|
if (!config?.baseUrl) {
|
|
13258
13590
|
this.learnDirectOrigin(response);
|
|
13259
13591
|
}
|
|
@@ -13261,11 +13593,14 @@ var HttpClient = class {
|
|
|
13261
13593
|
} catch (err) {
|
|
13262
13594
|
if (!config?.baseUrl && origin !== this.config.baseUrl && !isAbortError(err)) {
|
|
13263
13595
|
this.directDisabled = true;
|
|
13264
|
-
const response = await
|
|
13265
|
-
|
|
13266
|
-
|
|
13596
|
+
const response = await withAbort(
|
|
13597
|
+
fetch(`${this.config.baseUrl}${url}`, {
|
|
13598
|
+
...init,
|
|
13599
|
+
credentials: fetchCredentialsForPath(url),
|
|
13600
|
+
signal
|
|
13601
|
+
}),
|
|
13267
13602
|
signal
|
|
13268
|
-
|
|
13603
|
+
);
|
|
13269
13604
|
return { response, ok: response.ok, status: response.status };
|
|
13270
13605
|
}
|
|
13271
13606
|
throw err;
|
|
@@ -14224,6 +14559,8 @@ var index_default = ConnectBase;
|
|
|
14224
14559
|
GameRoomTransport,
|
|
14225
14560
|
NativeAPI,
|
|
14226
14561
|
OrganizationsAPI,
|
|
14562
|
+
RealtimeConnectionError,
|
|
14563
|
+
RealtimeRequestError,
|
|
14227
14564
|
RolesAPI,
|
|
14228
14565
|
SessionManager,
|
|
14229
14566
|
VideoProcessingError,
|