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/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.state === "connecting" && this.connectPromise) {
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.doConnect();
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
- async doConnect() {
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
- }, this.options.timeout);
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 Error(
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.notifyError(new Error("WebSocket connection error"));
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 Error("Failed to connect"));
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
- pending.reject(new Error(msg.error || "Unknown error"));
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
- const previousSubscriptions = /* @__PURE__ */ new Map();
9199
- for (const [category, sub] of this.subscriptions) {
9200
- previousSubscriptions.set(category, [...sub.handlers]);
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 delay = Math.min(
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
- await this.restoreSubscriptions(
9227
- previousSubscriptions,
9228
- previousPresenceSubscriptions,
9229
- previousTypingHandlers
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 info = {
9265
- category: response.category,
9266
- persist: response.persist,
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 is blocked on this network. Only AI streaming (realtime.stream) works in SSE fallback mode (cb.realtime.transport === 'sse').`
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 fetch(
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
- const advertised = response.headers.get("X-CB-Direct-Base");
13195
- if (advertised) {
13196
- try {
13197
- const next = new URL(advertised);
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
- const advertisedRealtime = response.headers.get(
13208
- "X-CB-Realtime-Direct-Base"
13209
- );
13210
- if (advertisedRealtime) {
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 fetch(`${origin}${url}`, {
13253
- ...init,
13254
- credentials: fetchCredentialsForPath(url),
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 fetch(`${this.config.baseUrl}${url}`, {
13265
- ...init,
13266
- credentials: fetchCredentialsForPath(url),
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,