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.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.state === "connecting" && this.connectPromise) {
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.doConnect();
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
- async doConnect() {
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
- }, this.options.timeout);
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 Error(
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.notifyError(new Error("WebSocket connection error"));
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 Error("Failed to connect"));
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
- pending.reject(new Error(msg.error || "Unknown error"));
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
- const previousSubscriptions = /* @__PURE__ */ new Map();
9149
- for (const [category, sub] of this.subscriptions) {
9150
- previousSubscriptions.set(category, [...sub.handlers]);
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 delay = Math.min(
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
- await this.restoreSubscriptions(
9177
- previousSubscriptions,
9178
- previousPresenceSubscriptions,
9179
- previousTypingHandlers
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 info = {
9215
- category: response.category,
9216
- persist: response.persist,
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 is blocked on this network. Only AI streaming (realtime.stream) works in SSE fallback mode (cb.realtime.transport === 'sse').`
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 fetch(
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
- const advertised = response.headers.get("X-CB-Direct-Base");
13145
- if (advertised) {
13146
- try {
13147
- const next = new URL(advertised);
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
- const advertisedRealtime = response.headers.get(
13158
- "X-CB-Realtime-Direct-Base"
13159
- );
13160
- if (advertisedRealtime) {
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 fetch(`${origin}${url}`, {
13203
- ...init,
13204
- credentials: fetchCredentialsForPath(url),
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 fetch(`${this.config.baseUrl}${url}`, {
13215
- ...init,
13216
- credentials: fetchCredentialsForPath(url),
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,