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.mjs
CHANGED
|
@@ -232,6 +232,49 @@ var GameError = class extends Error {
|
|
|
232
232
|
};
|
|
233
233
|
}
|
|
234
234
|
};
|
|
235
|
+
var RealtimeConnectionError = class extends Error {
|
|
236
|
+
constructor(init) {
|
|
237
|
+
super(init.message || init.code || "Realtime connection rejected");
|
|
238
|
+
this.name = "RealtimeConnectionError";
|
|
239
|
+
this.code = init.code || "unknown";
|
|
240
|
+
this.closeCode = init.closeCode;
|
|
241
|
+
this.retryable = init.retryable ?? isRetryableRealtimeRejection(this.code);
|
|
242
|
+
this.details = init.details;
|
|
243
|
+
this.retryAfterMs = init.retryAfterMs;
|
|
244
|
+
}
|
|
245
|
+
/** `JSON.stringify(err)` 가 `{}` 가 되지 않도록 한다 (ApiError.toJSON 참조). */
|
|
246
|
+
toJSON() {
|
|
247
|
+
return {
|
|
248
|
+
name: this.name,
|
|
249
|
+
code: this.code,
|
|
250
|
+
message: this.message,
|
|
251
|
+
closeCode: this.closeCode,
|
|
252
|
+
retryable: this.retryable,
|
|
253
|
+
details: this.details,
|
|
254
|
+
retryAfterMs: this.retryAfterMs
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
function isRetryableRealtimeRejection(code) {
|
|
259
|
+
return code === "connection_limit_exceeded" || code === "server_at_capacity" || code === "rate_limited" || code === "auth_unavailable" || code === "internal_error";
|
|
260
|
+
}
|
|
261
|
+
var RealtimeRequestError = class extends Error {
|
|
262
|
+
constructor(init) {
|
|
263
|
+
super(init.message || init.code || "Realtime request failed");
|
|
264
|
+
this.name = "RealtimeRequestError";
|
|
265
|
+
this.code = init.code || "unknown";
|
|
266
|
+
this.category = init.category;
|
|
267
|
+
}
|
|
268
|
+
/** `JSON.stringify(err)` 가 `{}` 가 되지 않도록 한다 (ApiError.toJSON 참조). */
|
|
269
|
+
toJSON() {
|
|
270
|
+
return {
|
|
271
|
+
name: this.name,
|
|
272
|
+
code: this.code,
|
|
273
|
+
message: this.message,
|
|
274
|
+
category: this.category
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
};
|
|
235
278
|
|
|
236
279
|
// src/api/ai.ts
|
|
237
280
|
function aiErrorToApiError(err) {
|
|
@@ -7874,6 +7917,44 @@ var QueueAPI = class {
|
|
|
7874
7917
|
|
|
7875
7918
|
// src/api/realtime.ts
|
|
7876
7919
|
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7920
|
+
function toConnectionRejection(msg) {
|
|
7921
|
+
if (msg.event !== "error" || msg.request_id) return null;
|
|
7922
|
+
const data = msg.data;
|
|
7923
|
+
if (!data || typeof data.close_code !== "number") return null;
|
|
7924
|
+
return new RealtimeConnectionError({
|
|
7925
|
+
code: data.code,
|
|
7926
|
+
message: data.message || msg.error,
|
|
7927
|
+
closeCode: data.close_code,
|
|
7928
|
+
retryable: data.retryable,
|
|
7929
|
+
details: data.details,
|
|
7930
|
+
retryAfterMs: typeof data.retry_after_ms === "number" && data.retry_after_ms > 0 ? data.retry_after_ms : void 0
|
|
7931
|
+
});
|
|
7932
|
+
}
|
|
7933
|
+
var HandshakeFailedError = class extends Error {
|
|
7934
|
+
};
|
|
7935
|
+
var ConnectCancelledError = class extends Error {
|
|
7936
|
+
constructor() {
|
|
7937
|
+
super("Realtime connect cancelled by disconnect()");
|
|
7938
|
+
this.name = "AbortError";
|
|
7939
|
+
}
|
|
7940
|
+
};
|
|
7941
|
+
var MAX_HANDSHAKE_RETRIES = 2;
|
|
7942
|
+
function jitter(max) {
|
|
7943
|
+
return Math.floor(Math.random() * Math.max(0, max));
|
|
7944
|
+
}
|
|
7945
|
+
function toSendPolicy(value) {
|
|
7946
|
+
return value === "upstream" || value === "publishers_only" ? value : "open";
|
|
7947
|
+
}
|
|
7948
|
+
function toCategoryInfo(response) {
|
|
7949
|
+
return {
|
|
7950
|
+
category: response.category,
|
|
7951
|
+
persist: response.persist,
|
|
7952
|
+
historyCount: response.history_count,
|
|
7953
|
+
readReceipt: response.read_receipt,
|
|
7954
|
+
isPublisher: response.publisher === true,
|
|
7955
|
+
sendPolicy: toSendPolicy(response.send_policy)
|
|
7956
|
+
};
|
|
7957
|
+
}
|
|
7877
7958
|
var RealtimeAPI = class {
|
|
7878
7959
|
constructor(http, socketUrl) {
|
|
7879
7960
|
this.ws = null;
|
|
@@ -7901,6 +7982,28 @@ var RealtimeAPI = class {
|
|
|
7901
7982
|
fallback: "sse"
|
|
7902
7983
|
};
|
|
7903
7984
|
this.retryCount = 0;
|
|
7985
|
+
/**
|
|
7986
|
+
* 재연결 뒤 복구할 구독 상태. 연결이 끊긴 첫 순간에 떠 두고, 재연결이 성공해 복구한 뒤 비운다.
|
|
7987
|
+
*
|
|
7988
|
+
* 재연결 시도가 실패할 때마다 onclose → handleDisconnect 가 다시 불린다. 예전에는 그때마다 구독 맵을
|
|
7989
|
+
* 새로 떴는데, 첫 끊김에서 이미 비워진 맵이라 빈 스냅샷으로 덮였다 — 첫 재연결 시도가 실패하면
|
|
7990
|
+
* (서버 롤링 재시작 등) 재연결에 성공해도 구독이 하나도 복구되지 않았다.
|
|
7991
|
+
*/
|
|
7992
|
+
this.pendingRestore = null;
|
|
7993
|
+
/** 첫 연결 전에 direct 원본 힌트를 한 번 물었는지 (primeDirectRealtime). */
|
|
7994
|
+
this.directRealtimePrimed = false;
|
|
7995
|
+
/** disconnect() 마다 늘린다 — connect() 의 재시도 대기가 그 사이 취소됐는지 알아보는 데 쓴다. */
|
|
7996
|
+
this.connectGeneration = 0;
|
|
7997
|
+
/**
|
|
7998
|
+
* connect() 가 첫 연결을 시도하는 중인지. 이때의 핸드셰이크 실패는 connect() 가 직접 다시 시도하거나
|
|
7999
|
+
* 폴백하므로, onclose 가 백그라운드 재연결 체인(handleDisconnect)을 따로 시작하면 안 된다 — 두 경로가
|
|
8000
|
+
* 동시에 연결을 열게 된다.
|
|
8001
|
+
*/
|
|
8002
|
+
this.initialConnecting = false;
|
|
8003
|
+
/** 다음 재연결 대기의 하한(ms) — 서버가 알려 준 retry_after_ms. handleDisconnect 가 한 번 쓰고 비운다. */
|
|
8004
|
+
this.reconnectFloorMs = 0;
|
|
8005
|
+
/** connect() 의 재시도 대기를 끝내는 함수 — disconnect() 가 기다림 없이 바로 취소하게 한다. */
|
|
8006
|
+
this.cancelConnectWait = null;
|
|
7904
8007
|
this.pendingRequests = /* @__PURE__ */ new Map();
|
|
7905
8008
|
this.subscriptions = /* @__PURE__ */ new Map();
|
|
7906
8009
|
this.streamSessions = /* @__PURE__ */ new Map();
|
|
@@ -7944,7 +8047,7 @@ var RealtimeAPI = class {
|
|
|
7944
8047
|
if (this.state === "connected") {
|
|
7945
8048
|
return;
|
|
7946
8049
|
}
|
|
7947
|
-
if (this.
|
|
8050
|
+
if (this.connectPromise) {
|
|
7948
8051
|
return this.connectPromise;
|
|
7949
8052
|
}
|
|
7950
8053
|
this.options = { ...this.options, ...options };
|
|
@@ -7956,10 +8059,15 @@ var RealtimeAPI = class {
|
|
|
7956
8059
|
`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.`
|
|
7957
8060
|
);
|
|
7958
8061
|
}
|
|
7959
|
-
this.connectPromise = this.
|
|
8062
|
+
this.connectPromise = this.primeDirectRealtime().then(
|
|
8063
|
+
() => this.connectWithRetry()
|
|
8064
|
+
);
|
|
7960
8065
|
try {
|
|
7961
8066
|
await this.connectPromise;
|
|
7962
8067
|
} catch (e) {
|
|
8068
|
+
if (e instanceof RealtimeConnectionError || e instanceof ConnectCancelledError) {
|
|
8069
|
+
throw e;
|
|
8070
|
+
}
|
|
7963
8071
|
const hasAuth = !!(this.options.accessToken || this.http.getPublicKey());
|
|
7964
8072
|
if (this.options.fallback !== "none" && hasAuth) {
|
|
7965
8073
|
this.activateSseFallback(e);
|
|
@@ -7988,6 +8096,7 @@ var RealtimeAPI = class {
|
|
|
7988
8096
|
this.activeTransport = "sse";
|
|
7989
8097
|
this.state = "connected";
|
|
7990
8098
|
this.retryCount = 0;
|
|
8099
|
+
this.pendingRestore = null;
|
|
7991
8100
|
this.log(
|
|
7992
8101
|
`WS unavailable (${causeMsg}); using SSE fallback transport for AI streaming`
|
|
7993
8102
|
);
|
|
@@ -8002,6 +8111,8 @@ var RealtimeAPI = class {
|
|
|
8002
8111
|
* 연결 해제
|
|
8003
8112
|
*/
|
|
8004
8113
|
disconnect() {
|
|
8114
|
+
this.connectGeneration++;
|
|
8115
|
+
this.cancelConnectWait?.();
|
|
8005
8116
|
this.state = "disconnected";
|
|
8006
8117
|
this.notifyStateChange();
|
|
8007
8118
|
if (this.ws) {
|
|
@@ -8044,6 +8155,7 @@ var RealtimeAPI = class {
|
|
|
8044
8155
|
this._appId = null;
|
|
8045
8156
|
this.retryCount = 0;
|
|
8046
8157
|
this.connectPromise = null;
|
|
8158
|
+
this.pendingRestore = null;
|
|
8047
8159
|
}
|
|
8048
8160
|
/**
|
|
8049
8161
|
* 카테고리 구독
|
|
@@ -8059,12 +8171,7 @@ var RealtimeAPI = class {
|
|
|
8059
8171
|
action: "subscribe",
|
|
8060
8172
|
request_id: requestId
|
|
8061
8173
|
});
|
|
8062
|
-
const info =
|
|
8063
|
-
category: response.category,
|
|
8064
|
-
persist: response.persist,
|
|
8065
|
-
historyCount: response.history_count,
|
|
8066
|
-
readReceipt: response.read_receipt
|
|
8067
|
-
};
|
|
8174
|
+
const info = toCategoryInfo(response);
|
|
8068
8175
|
const handlers = [];
|
|
8069
8176
|
this.subscriptions.set(category, { info, handlers });
|
|
8070
8177
|
const subscription = {
|
|
@@ -8120,7 +8227,7 @@ var RealtimeAPI = class {
|
|
|
8120
8227
|
await this.sendRequest({
|
|
8121
8228
|
category,
|
|
8122
8229
|
action: "send",
|
|
8123
|
-
data: { data, broadcast },
|
|
8230
|
+
data: options.to ? { data, broadcast, to: options.to } : { data, broadcast },
|
|
8124
8231
|
request_id: requestId
|
|
8125
8232
|
});
|
|
8126
8233
|
}
|
|
@@ -8826,7 +8933,92 @@ var RealtimeAPI = class {
|
|
|
8826
8933
|
};
|
|
8827
8934
|
}
|
|
8828
8935
|
// Private methods
|
|
8829
|
-
|
|
8936
|
+
/**
|
|
8937
|
+
* direct(저지연) 원본을 아직 모르면 첫 연결 전에 한 번 묻는다 — `GET /v1/public/edge-hints`.
|
|
8938
|
+
*
|
|
8939
|
+
* direct 원본은 core-server 응답 헤더(`X-CB-Realtime-Direct-Base`)로만 배운다. realtime 만 쓰는
|
|
8940
|
+
* 페이지는 WS 를 열기 전에 REST 를 부르지 않아 배울 기회가 없었고, 첫 연결이 늘 Cloudflare 를 타
|
|
8941
|
+
* 한국 사용자는 미국(LAX)을 거친 ~270ms 로 붙었다(platform-issue 01a15d9d 의 273ms). 응답 헤더는
|
|
8942
|
+
* HttpClient 가 평소처럼 학습한다. 광고 대상이 아니면(해외, 킬 스위치) 빈 응답이라 원래 소켓
|
|
8943
|
+
* URL 로 간다. 실패하거나 느리면(1.5초) 그냥 넘어간다 — 최적화일 뿐 연결의 전제가 아니다.
|
|
8944
|
+
* 세션당 한 번만 묻는다.
|
|
8945
|
+
*/
|
|
8946
|
+
async primeDirectRealtime() {
|
|
8947
|
+
if (this.directRealtimePrimed) return;
|
|
8948
|
+
this.directRealtimePrimed = true;
|
|
8949
|
+
if (this.http.getDirectRealtimeBaseUrl()) {
|
|
8950
|
+
return;
|
|
8951
|
+
}
|
|
8952
|
+
try {
|
|
8953
|
+
await this.http.primeEdgeHints(1500);
|
|
8954
|
+
} catch (e) {
|
|
8955
|
+
this.log(
|
|
8956
|
+
`edge hint lookup failed, using default socket URL: ${e instanceof Error ? e.message : String(e)}`
|
|
8957
|
+
);
|
|
8958
|
+
}
|
|
8959
|
+
}
|
|
8960
|
+
/**
|
|
8961
|
+
* 첫 연결을 연결 제한 시간(`timeout`) 안에서 다시 시도한다 (6.6.0+, platform-issue 01a15e24).
|
|
8962
|
+
*
|
|
8963
|
+
* - 서버가 재시도 시각을 알려 준 거부(`retryAfterMs`, 예: 한 IP 에서 연결이 몰린 `rate_limited`):
|
|
8964
|
+
* 그 시각에 지터를 더해 다시 붙는다. 공연 시작처럼 한꺼번에 들어올 때 앱이 재시도를 짜지 않아도 된다.
|
|
8965
|
+
* - 이유 없는 핸드셰이크 실패: 두 번까지 백오프 후 다시 한다. 네트워크가 WebSocket 을 실제로 막으면
|
|
8966
|
+
* 매번 실패하고, 앞단의 일시 거부는 다시 하면 붙는다 — 한 번 실패로 SSE 폴백하던 것이 연결 속도
|
|
8967
|
+
* 제한을 "네트워크 차단" 으로 오진한 원인이었다.
|
|
8968
|
+
* - 그 밖의 거부(자격증명, 동시접속 한도 등)와 시간 초과는 바로 던진다.
|
|
8969
|
+
*/
|
|
8970
|
+
async connectWithRetry() {
|
|
8971
|
+
const generation = this.connectGeneration;
|
|
8972
|
+
const deadline = Date.now() + this.options.timeout;
|
|
8973
|
+
let handshakeFailures = 0;
|
|
8974
|
+
this.initialConnecting = true;
|
|
8975
|
+
try {
|
|
8976
|
+
for (let attempt = 1; ; attempt++) {
|
|
8977
|
+
try {
|
|
8978
|
+
await this.doConnect(Math.max(deadline - Date.now(), 1));
|
|
8979
|
+
return;
|
|
8980
|
+
} catch (e) {
|
|
8981
|
+
let wait = null;
|
|
8982
|
+
if (e instanceof RealtimeConnectionError) {
|
|
8983
|
+
if (e.retryable && e.retryAfterMs !== void 0) {
|
|
8984
|
+
wait = e.retryAfterMs + jitter(Math.min(100 * 2 ** attempt, 3e3));
|
|
8985
|
+
} else if (e.code === "rate_limited") {
|
|
8986
|
+
const base = Math.min(500 * 2 ** (attempt - 1), 8e3);
|
|
8987
|
+
wait = base / 2 + jitter(base / 2);
|
|
8988
|
+
}
|
|
8989
|
+
} else if (e instanceof HandshakeFailedError && handshakeFailures < MAX_HANDSHAKE_RETRIES) {
|
|
8990
|
+
handshakeFailures++;
|
|
8991
|
+
const base = 500 * 2 ** (handshakeFailures - 1);
|
|
8992
|
+
wait = base / 2 + jitter(base / 2);
|
|
8993
|
+
}
|
|
8994
|
+
if (wait === null || Date.now() + wait >= deadline) {
|
|
8995
|
+
if (this.state === "connecting") {
|
|
8996
|
+
this.state = "disconnected";
|
|
8997
|
+
this.notifyStateChange();
|
|
8998
|
+
}
|
|
8999
|
+
throw e;
|
|
9000
|
+
}
|
|
9001
|
+
this.log(
|
|
9002
|
+
`Connect attempt ${attempt} failed (${e instanceof Error ? e.message : String(e)}); retrying in ${wait}ms`
|
|
9003
|
+
);
|
|
9004
|
+
await new Promise((resolve) => {
|
|
9005
|
+
const timer = setTimeout(resolve, wait);
|
|
9006
|
+
this.cancelConnectWait = () => {
|
|
9007
|
+
clearTimeout(timer);
|
|
9008
|
+
resolve();
|
|
9009
|
+
};
|
|
9010
|
+
});
|
|
9011
|
+
this.cancelConnectWait = null;
|
|
9012
|
+
if (generation !== this.connectGeneration) {
|
|
9013
|
+
throw new ConnectCancelledError();
|
|
9014
|
+
}
|
|
9015
|
+
}
|
|
9016
|
+
}
|
|
9017
|
+
} finally {
|
|
9018
|
+
this.initialConnecting = false;
|
|
9019
|
+
}
|
|
9020
|
+
}
|
|
9021
|
+
async doConnect(timeoutMs = this.options.timeout) {
|
|
8830
9022
|
return new Promise((resolve, reject) => {
|
|
8831
9023
|
this.state = "connecting";
|
|
8832
9024
|
this.notifyStateChange();
|
|
@@ -8854,7 +9046,9 @@ var RealtimeAPI = class {
|
|
|
8854
9046
|
if (this.userId) {
|
|
8855
9047
|
url += `&user_id=${encodeURIComponent(this.userId)}`;
|
|
8856
9048
|
}
|
|
9049
|
+
url += "&reject_mode=close";
|
|
8857
9050
|
let settled = false;
|
|
9051
|
+
let rejection = null;
|
|
8858
9052
|
const timeoutId = setTimeout(() => {
|
|
8859
9053
|
if (!settled) {
|
|
8860
9054
|
settled = true;
|
|
@@ -8870,7 +9064,7 @@ var RealtimeAPI = class {
|
|
|
8870
9064
|
new Error(`Connection timeout after ${this.options.timeout}ms`)
|
|
8871
9065
|
);
|
|
8872
9066
|
}
|
|
8873
|
-
},
|
|
9067
|
+
}, timeoutMs);
|
|
8874
9068
|
try {
|
|
8875
9069
|
this.log(`Connecting to ${wsUrl}`);
|
|
8876
9070
|
this.ws = new WebSocket(url);
|
|
@@ -8882,6 +9076,11 @@ var RealtimeAPI = class {
|
|
|
8882
9076
|
for (const line of messages) {
|
|
8883
9077
|
try {
|
|
8884
9078
|
const msg = JSON.parse(line);
|
|
9079
|
+
const rejected = toConnectionRejection(msg);
|
|
9080
|
+
if (rejected && !settled) {
|
|
9081
|
+
rejection = rejected;
|
|
9082
|
+
continue;
|
|
9083
|
+
}
|
|
8885
9084
|
this.handleServerMessage(msg, () => {
|
|
8886
9085
|
if (!settled) {
|
|
8887
9086
|
settled = true;
|
|
@@ -8899,16 +9098,45 @@ var RealtimeAPI = class {
|
|
|
8899
9098
|
this.log(
|
|
8900
9099
|
`WebSocket closed: code=${event.code}, reason=${event.reason}`
|
|
8901
9100
|
);
|
|
9101
|
+
if (event.code >= 4e3 && event.code <= 4999) {
|
|
9102
|
+
const err = rejection ?? new RealtimeConnectionError({
|
|
9103
|
+
code: event.reason || void 0,
|
|
9104
|
+
closeCode: event.code
|
|
9105
|
+
});
|
|
9106
|
+
const wasConnected = this.state === "connected";
|
|
9107
|
+
if (!settled) {
|
|
9108
|
+
settled = true;
|
|
9109
|
+
clearTimeout(timeoutId);
|
|
9110
|
+
}
|
|
9111
|
+
this.ws = null;
|
|
9112
|
+
if (wasConnected) {
|
|
9113
|
+
if (err.retryable) {
|
|
9114
|
+
this.handleDisconnect();
|
|
9115
|
+
} else {
|
|
9116
|
+
this.failRealtime(err);
|
|
9117
|
+
}
|
|
9118
|
+
return;
|
|
9119
|
+
}
|
|
9120
|
+
this.state = "disconnected";
|
|
9121
|
+
this.notifyStateChange();
|
|
9122
|
+
reject(err);
|
|
9123
|
+
return;
|
|
9124
|
+
}
|
|
8902
9125
|
if (!settled && this.state === "connecting") {
|
|
8903
9126
|
settled = true;
|
|
8904
9127
|
clearTimeout(timeoutId);
|
|
8905
9128
|
if (usingDirect) this.http.disableDirectRealtime();
|
|
8906
9129
|
reject(
|
|
8907
|
-
new
|
|
9130
|
+
new HandshakeFailedError(
|
|
8908
9131
|
`Connection closed: ${event.reason || "unknown reason"}`
|
|
8909
9132
|
)
|
|
8910
9133
|
);
|
|
8911
9134
|
}
|
|
9135
|
+
if (this.initialConnecting && this.state === "connecting") {
|
|
9136
|
+
this.state = "disconnected";
|
|
9137
|
+
this.ws = null;
|
|
9138
|
+
return;
|
|
9139
|
+
}
|
|
8912
9140
|
if (this.state === "connected" || this.state === "connecting") {
|
|
8913
9141
|
this.handleDisconnect();
|
|
8914
9142
|
}
|
|
@@ -8916,12 +9144,14 @@ var RealtimeAPI = class {
|
|
|
8916
9144
|
this.ws.onerror = (error) => {
|
|
8917
9145
|
this.log("WebSocket error occurred");
|
|
8918
9146
|
this.logError("WebSocket error", error);
|
|
8919
|
-
this.
|
|
9147
|
+
if (!this.initialConnecting) {
|
|
9148
|
+
this.notifyError(new Error("WebSocket connection error"));
|
|
9149
|
+
}
|
|
8920
9150
|
if (!settled && this.state === "connecting") {
|
|
8921
9151
|
settled = true;
|
|
8922
9152
|
clearTimeout(timeoutId);
|
|
8923
9153
|
if (usingDirect) this.http.disableDirectRealtime();
|
|
8924
|
-
reject(new
|
|
9154
|
+
reject(new HandshakeFailedError("Failed to connect"));
|
|
8925
9155
|
}
|
|
8926
9156
|
};
|
|
8927
9157
|
} catch (e) {
|
|
@@ -8985,7 +9215,8 @@ var RealtimeAPI = class {
|
|
|
8985
9215
|
category: data.category,
|
|
8986
9216
|
from: data.from,
|
|
8987
9217
|
data: data.data,
|
|
8988
|
-
sentAt: data.sent_at
|
|
9218
|
+
sentAt: data.sent_at,
|
|
9219
|
+
fromPublisher: data.from_publisher === true
|
|
8989
9220
|
};
|
|
8990
9221
|
sub.handlers.forEach((h) => h(message));
|
|
8991
9222
|
}
|
|
@@ -8996,7 +9227,14 @@ var RealtimeAPI = class {
|
|
|
8996
9227
|
const pending = this.pendingRequests.get(msg.request_id);
|
|
8997
9228
|
if (pending) {
|
|
8998
9229
|
clearTimeout(pending.timeout);
|
|
8999
|
-
|
|
9230
|
+
const errData = msg.data;
|
|
9231
|
+
pending.reject(
|
|
9232
|
+
new RealtimeRequestError({
|
|
9233
|
+
code: errData?.code,
|
|
9234
|
+
message: msg.error || "Unknown error",
|
|
9235
|
+
category: msg.category
|
|
9236
|
+
})
|
|
9237
|
+
);
|
|
9000
9238
|
this.pendingRequests.delete(msg.request_id);
|
|
9001
9239
|
}
|
|
9002
9240
|
} else {
|
|
@@ -9145,41 +9383,66 @@ var RealtimeAPI = class {
|
|
|
9145
9383
|
}
|
|
9146
9384
|
});
|
|
9147
9385
|
this.streamSessions.clear();
|
|
9148
|
-
|
|
9149
|
-
|
|
9150
|
-
|
|
9386
|
+
if (!this.pendingRestore) {
|
|
9387
|
+
const subscriptions = /* @__PURE__ */ new Map();
|
|
9388
|
+
const infos = /* @__PURE__ */ new Map();
|
|
9389
|
+
for (const [category, sub] of this.subscriptions) {
|
|
9390
|
+
subscriptions.set(category, [...sub.handlers]);
|
|
9391
|
+
infos.set(category, sub.info);
|
|
9392
|
+
}
|
|
9393
|
+
const presence = /* @__PURE__ */ new Map();
|
|
9394
|
+
for (const [userId, handlers] of this.presenceSubscriptions) {
|
|
9395
|
+
presence.set(userId, [...handlers]);
|
|
9396
|
+
}
|
|
9397
|
+
const typing = /* @__PURE__ */ new Map();
|
|
9398
|
+
for (const [roomId, handlers] of this.typingHandlers) {
|
|
9399
|
+
typing.set(roomId, [...handlers]);
|
|
9400
|
+
}
|
|
9401
|
+
this.pendingRestore = { subscriptions, infos, presence, typing };
|
|
9151
9402
|
}
|
|
9152
9403
|
this.subscriptions.clear();
|
|
9153
|
-
const previousPresenceSubscriptions = /* @__PURE__ */ new Map();
|
|
9154
|
-
for (const [userId, handlers] of this.presenceSubscriptions) {
|
|
9155
|
-
previousPresenceSubscriptions.set(userId, [...handlers]);
|
|
9156
|
-
}
|
|
9157
9404
|
this.presenceSubscriptions.clear();
|
|
9158
|
-
const previousTypingHandlers = /* @__PURE__ */ new Map();
|
|
9159
|
-
for (const [roomId, handlers] of this.typingHandlers) {
|
|
9160
|
-
previousTypingHandlers.set(roomId, [...handlers]);
|
|
9161
|
-
}
|
|
9162
9405
|
this.typingHandlers.clear();
|
|
9163
9406
|
if (this.retryCount < this.options.maxRetries) {
|
|
9164
9407
|
this.state = "reconnecting";
|
|
9165
9408
|
this.notifyStateChange();
|
|
9166
9409
|
this.retryCount++;
|
|
9167
|
-
const
|
|
9410
|
+
const base = Math.min(
|
|
9168
9411
|
this.options.retryInterval * 2 ** (this.retryCount - 1),
|
|
9169
9412
|
3e4
|
|
9170
9413
|
);
|
|
9414
|
+
const delay = Math.max(
|
|
9415
|
+
Math.ceil(base / 2) + jitter(Math.floor(base / 2)),
|
|
9416
|
+
this.reconnectFloorMs
|
|
9417
|
+
);
|
|
9418
|
+
this.reconnectFloorMs = 0;
|
|
9171
9419
|
setTimeout(async () => {
|
|
9172
9420
|
if (this.sseFallbackActive) return;
|
|
9421
|
+
if (this.state !== "reconnecting") return;
|
|
9173
9422
|
try {
|
|
9174
9423
|
await this.doConnect();
|
|
9175
9424
|
this.log("Reconnected successfully, restoring subscriptions...");
|
|
9176
|
-
|
|
9177
|
-
|
|
9178
|
-
|
|
9179
|
-
|
|
9180
|
-
|
|
9425
|
+
const snapshot = this.pendingRestore;
|
|
9426
|
+
this.pendingRestore = null;
|
|
9427
|
+
if (snapshot) {
|
|
9428
|
+
await this.restoreSubscriptions(
|
|
9429
|
+
snapshot.subscriptions,
|
|
9430
|
+
snapshot.presence,
|
|
9431
|
+
snapshot.typing,
|
|
9432
|
+
snapshot.infos
|
|
9433
|
+
);
|
|
9434
|
+
}
|
|
9181
9435
|
} catch (e) {
|
|
9182
9436
|
this.logError("Reconnect failed", e);
|
|
9437
|
+
if (e instanceof RealtimeConnectionError) {
|
|
9438
|
+
if (e.retryable && this.retryCount < this.options.maxRetries) {
|
|
9439
|
+
this.reconnectFloorMs = e.retryAfterMs ?? 0;
|
|
9440
|
+
this.handleDisconnect();
|
|
9441
|
+
} else {
|
|
9442
|
+
this.failRealtime(e);
|
|
9443
|
+
}
|
|
9444
|
+
return;
|
|
9445
|
+
}
|
|
9183
9446
|
if (this.retryCount >= this.options.maxRetries && this.options.fallback !== "none" && !this.sseFallbackActive) {
|
|
9184
9447
|
this.activateSseFallback(e);
|
|
9185
9448
|
}
|
|
@@ -9197,11 +9460,23 @@ var RealtimeAPI = class {
|
|
|
9197
9460
|
}
|
|
9198
9461
|
}
|
|
9199
9462
|
}
|
|
9463
|
+
/**
|
|
9464
|
+
* 재연결을 멈추고 앱에 알린다 — 서버가 재시도해도 소용없는 이유(자격증명, 할당량)로 거부했거나
|
|
9465
|
+
* 재시도할 수 있는 거부가 maxRetries 를 다 썼다.
|
|
9466
|
+
*/
|
|
9467
|
+
failRealtime(err) {
|
|
9468
|
+
this.pendingRestore = null;
|
|
9469
|
+
this.ws = null;
|
|
9470
|
+
this._connectionId = null;
|
|
9471
|
+
this.state = "disconnected";
|
|
9472
|
+
this.notifyStateChange();
|
|
9473
|
+
this.notifyError(err);
|
|
9474
|
+
}
|
|
9200
9475
|
/**
|
|
9201
9476
|
* 이전 구독 상태를 복구합니다.
|
|
9202
9477
|
* 재연결 성공 후 호출됩니다.
|
|
9203
9478
|
*/
|
|
9204
|
-
async restoreSubscriptions(previousSubscriptions, previousPresenceSubscriptions, previousTypingHandlers) {
|
|
9479
|
+
async restoreSubscriptions(previousSubscriptions, previousPresenceSubscriptions, previousTypingHandlers, previousInfos) {
|
|
9205
9480
|
for (const [category, handlers] of previousSubscriptions) {
|
|
9206
9481
|
try {
|
|
9207
9482
|
this.log(`Restoring subscription: ${category}`);
|
|
@@ -9211,12 +9486,9 @@ var RealtimeAPI = class {
|
|
|
9211
9486
|
action: "subscribe",
|
|
9212
9487
|
request_id: requestId
|
|
9213
9488
|
});
|
|
9214
|
-
const
|
|
9215
|
-
|
|
9216
|
-
|
|
9217
|
-
historyCount: response.history_count,
|
|
9218
|
-
readReceipt: response.read_receipt
|
|
9219
|
-
};
|
|
9489
|
+
const fresh = toCategoryInfo(response);
|
|
9490
|
+
const info = previousInfos?.get(category) ?? fresh;
|
|
9491
|
+
Object.assign(info, fresh);
|
|
9220
9492
|
this.subscriptions.set(category, { info, handlers });
|
|
9221
9493
|
this.log(`Restored subscription: ${category}`);
|
|
9222
9494
|
} catch (e) {
|
|
@@ -9269,7 +9541,7 @@ var RealtimeAPI = class {
|
|
|
9269
9541
|
assertBidirectional(feature) {
|
|
9270
9542
|
if (this.activeTransport === "sse") {
|
|
9271
9543
|
throw new Error(
|
|
9272
|
-
`realtime.${feature}() requires a WebSocket connection, which
|
|
9544
|
+
`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').`
|
|
9273
9545
|
);
|
|
9274
9546
|
}
|
|
9275
9547
|
}
|
|
@@ -9423,6 +9695,28 @@ function createTimeoutController(options = {}) {
|
|
|
9423
9695
|
}
|
|
9424
9696
|
};
|
|
9425
9697
|
}
|
|
9698
|
+
function withAbort(promise, signal) {
|
|
9699
|
+
if (!signal) return promise;
|
|
9700
|
+
const reason = () => signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
|
|
9701
|
+
if (signal.aborted) {
|
|
9702
|
+
promise.catch(() => void 0);
|
|
9703
|
+
return Promise.reject(reason());
|
|
9704
|
+
}
|
|
9705
|
+
return new Promise((resolve, reject) => {
|
|
9706
|
+
const onAbort = () => reject(reason());
|
|
9707
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
9708
|
+
promise.then(
|
|
9709
|
+
(value) => {
|
|
9710
|
+
signal.removeEventListener("abort", onAbort);
|
|
9711
|
+
resolve(value);
|
|
9712
|
+
},
|
|
9713
|
+
(error) => {
|
|
9714
|
+
signal.removeEventListener("abort", onAbort);
|
|
9715
|
+
reject(error);
|
|
9716
|
+
}
|
|
9717
|
+
);
|
|
9718
|
+
});
|
|
9719
|
+
}
|
|
9426
9720
|
|
|
9427
9721
|
// src/core/url-validation.ts
|
|
9428
9722
|
var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
|
|
@@ -12790,14 +13084,14 @@ var HttpClient = class {
|
|
|
12790
13084
|
if (credential) {
|
|
12791
13085
|
headers["X-Public-Key"] = credential;
|
|
12792
13086
|
}
|
|
12793
|
-
const response = await
|
|
12794
|
-
`${this.config.baseUrl}/v1/auth/re-issue`,
|
|
12795
|
-
{
|
|
13087
|
+
const response = await withAbort(
|
|
13088
|
+
fetch(`${this.config.baseUrl}/v1/auth/re-issue`, {
|
|
12796
13089
|
method: "POST",
|
|
12797
13090
|
headers,
|
|
12798
13091
|
credentials: "include",
|
|
12799
13092
|
signal
|
|
12800
|
-
}
|
|
13093
|
+
}),
|
|
13094
|
+
signal
|
|
12801
13095
|
);
|
|
12802
13096
|
if (!response.ok) {
|
|
12803
13097
|
const status = response.status;
|
|
@@ -13134,38 +13428,71 @@ var HttpClient = class {
|
|
|
13134
13428
|
*/
|
|
13135
13429
|
learnDirectOrigin(response) {
|
|
13136
13430
|
if (!response.headers) return;
|
|
13431
|
+
this.adoptDirectBases(
|
|
13432
|
+
response.headers.get("X-CB-Direct-Base"),
|
|
13433
|
+
response.headers.get("X-CB-Realtime-Direct-Base")
|
|
13434
|
+
);
|
|
13435
|
+
}
|
|
13436
|
+
/**
|
|
13437
|
+
* 광고된 direct 원본을 검증해 받아들인다. 응답 헤더(learnDirectOrigin)와 edge-hints 본문
|
|
13438
|
+
* (primeEdgeHints)이 같은 검증을 거친다.
|
|
13439
|
+
*/
|
|
13440
|
+
adoptDirectBases(advertised, advertisedRealtime) {
|
|
13137
13441
|
const tail = (h) => h.split(".").slice(-2).join(".");
|
|
13138
13442
|
let base = null;
|
|
13139
13443
|
try {
|
|
13140
13444
|
base = new URL(this.config.baseUrl);
|
|
13141
13445
|
} catch {
|
|
13142
13446
|
}
|
|
13143
|
-
if (base && !this.directDisabled && !this.directBaseUrl) {
|
|
13144
|
-
|
|
13145
|
-
|
|
13146
|
-
|
|
13147
|
-
|
|
13148
|
-
if (next.protocol === "https:" && tail(next.hostname) === tail(base.hostname)) {
|
|
13149
|
-
this.directBaseUrl = next.origin;
|
|
13150
|
-
}
|
|
13151
|
-
} catch {
|
|
13447
|
+
if (base && !this.directDisabled && !this.directBaseUrl && typeof advertised === "string" && advertised) {
|
|
13448
|
+
try {
|
|
13449
|
+
const next = new URL(advertised);
|
|
13450
|
+
if (next.protocol === "https:" && tail(next.hostname) === tail(base.hostname)) {
|
|
13451
|
+
this.directBaseUrl = next.origin;
|
|
13152
13452
|
}
|
|
13453
|
+
} catch {
|
|
13153
13454
|
}
|
|
13154
13455
|
}
|
|
13155
|
-
if (!this.directRealtimeDisabled && !this.directRealtimeBaseUrl) {
|
|
13456
|
+
if (!this.directRealtimeDisabled && !this.directRealtimeBaseUrl && typeof advertisedRealtime === "string" && advertisedRealtime) {
|
|
13156
13457
|
const expectedTail = base ? tail(base.hostname) : "connectbase.world";
|
|
13157
|
-
|
|
13158
|
-
|
|
13159
|
-
|
|
13160
|
-
|
|
13161
|
-
try {
|
|
13162
|
-
const next = new URL(advertisedRealtime);
|
|
13163
|
-
if (next.protocol === "wss:" && tail(next.hostname) === expectedTail) {
|
|
13164
|
-
this.directRealtimeBaseUrl = next.origin;
|
|
13165
|
-
}
|
|
13166
|
-
} catch {
|
|
13458
|
+
try {
|
|
13459
|
+
const next = new URL(advertisedRealtime);
|
|
13460
|
+
if (next.protocol === "wss:" && tail(next.hostname) === expectedTail) {
|
|
13461
|
+
this.directRealtimeBaseUrl = next.origin;
|
|
13167
13462
|
}
|
|
13463
|
+
} catch {
|
|
13464
|
+
}
|
|
13465
|
+
}
|
|
13466
|
+
}
|
|
13467
|
+
/**
|
|
13468
|
+
* realtime 첫 연결 전에 저지연 direct 원본 힌트를 묻는다 (`GET /v1/public/edge-hints`, 6.6.0+).
|
|
13469
|
+
*
|
|
13470
|
+
* **커스텀 헤더 없는 단순 GET** 이다. `X-Public-Key` 같은 헤더가 붙으면 브라우저가 CORS 사전 요청을
|
|
13471
|
+
* 먼저 보내 Cloudflare 를 두 번 왕복하고, 앱의 "로컬 개발 origin 허용" 이 꺼진 localhost 페이지에서는
|
|
13472
|
+
* 키 기반 opt-in 에 막혀 브라우저만 direct 를 못 썼다(Node 는 썼다 — platform-issue 01a15e24).
|
|
13473
|
+
* 서버는 이 경로를 키 없이 열어 두고 같은 값을 본문으로도 준다. 본문을 읽으므로 CORS
|
|
13474
|
+
* Expose-Headers 에도 기대지 않는다. 실패는 조용히 무시한다 — direct 는 최적화일 뿐이다.
|
|
13475
|
+
*/
|
|
13476
|
+
async primeEdgeHints(timeoutMs) {
|
|
13477
|
+
if (this.directRealtimeBaseUrl || this.directRealtimeDisabled) return;
|
|
13478
|
+
const { signal, cleanup } = createTimeoutController({ timeout: timeoutMs });
|
|
13479
|
+
try {
|
|
13480
|
+
const response = await withAbort(
|
|
13481
|
+
fetch(`${this.config.baseUrl}/v1/public/edge-hints`, {
|
|
13482
|
+
method: "GET",
|
|
13483
|
+
credentials: "omit",
|
|
13484
|
+
signal
|
|
13485
|
+
}),
|
|
13486
|
+
signal
|
|
13487
|
+
);
|
|
13488
|
+
if (!response.ok) {
|
|
13489
|
+
throw new Error(`edge-hints HTTP ${response.status}`);
|
|
13168
13490
|
}
|
|
13491
|
+
this.learnDirectOrigin(response);
|
|
13492
|
+
const body = await withAbort(response.json(), signal);
|
|
13493
|
+
this.adoptDirectBases(body?.direct_base, body?.realtime_direct_base);
|
|
13494
|
+
} finally {
|
|
13495
|
+
cleanup();
|
|
13169
13496
|
}
|
|
13170
13497
|
}
|
|
13171
13498
|
/**
|
|
@@ -13199,11 +13526,14 @@ var HttpClient = class {
|
|
|
13199
13526
|
});
|
|
13200
13527
|
const origin = this.resolveOrigin(url, config?.baseUrl);
|
|
13201
13528
|
try {
|
|
13202
|
-
const response = await
|
|
13203
|
-
|
|
13204
|
-
|
|
13529
|
+
const response = await withAbort(
|
|
13530
|
+
fetch(`${origin}${url}`, {
|
|
13531
|
+
...init,
|
|
13532
|
+
credentials: fetchCredentialsForPath(url),
|
|
13533
|
+
signal
|
|
13534
|
+
}),
|
|
13205
13535
|
signal
|
|
13206
|
-
|
|
13536
|
+
);
|
|
13207
13537
|
if (!config?.baseUrl) {
|
|
13208
13538
|
this.learnDirectOrigin(response);
|
|
13209
13539
|
}
|
|
@@ -13211,11 +13541,14 @@ var HttpClient = class {
|
|
|
13211
13541
|
} catch (err) {
|
|
13212
13542
|
if (!config?.baseUrl && origin !== this.config.baseUrl && !isAbortError(err)) {
|
|
13213
13543
|
this.directDisabled = true;
|
|
13214
|
-
const response = await
|
|
13215
|
-
|
|
13216
|
-
|
|
13544
|
+
const response = await withAbort(
|
|
13545
|
+
fetch(`${this.config.baseUrl}${url}`, {
|
|
13546
|
+
...init,
|
|
13547
|
+
credentials: fetchCredentialsForPath(url),
|
|
13548
|
+
signal
|
|
13549
|
+
}),
|
|
13217
13550
|
signal
|
|
13218
|
-
|
|
13551
|
+
);
|
|
13219
13552
|
return { response, ok: response.ok, status: response.status };
|
|
13220
13553
|
}
|
|
13221
13554
|
throw err;
|
|
@@ -14173,6 +14506,8 @@ export {
|
|
|
14173
14506
|
GameRoomTransport,
|
|
14174
14507
|
NativeAPI,
|
|
14175
14508
|
OrganizationsAPI,
|
|
14509
|
+
RealtimeConnectionError,
|
|
14510
|
+
RealtimeRequestError,
|
|
14176
14511
|
RolesAPI,
|
|
14177
14512
|
SessionManager,
|
|
14178
14513
|
VideoProcessingError,
|