connectbase-client 6.13.0 → 6.15.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
@@ -118,9 +118,9 @@ var AdsAPI = class {
118
118
 
119
119
  // src/types/error.ts
120
120
  var ApiError = class extends Error {
121
- constructor(statusCode, message, code, details) {
121
+ constructor(statusCode2, message, code, details) {
122
122
  super(message);
123
- this.statusCode = statusCode;
123
+ this.statusCode = statusCode2;
124
124
  this.name = "ApiError";
125
125
  this.code = code;
126
126
  this.details = details;
@@ -238,7 +238,7 @@ var RealtimeConnectionError = class extends Error {
238
238
  this.name = "RealtimeConnectionError";
239
239
  this.code = init.code || "unknown";
240
240
  this.closeCode = init.closeCode;
241
- this.retryable = init.retryable ?? isRetryableRealtimeRejection(this.code);
241
+ this.retryable = init.retryable ?? isRetryableRealtimeRejection(this.code, init.closeCode);
242
242
  this.details = init.details;
243
243
  this.retryAfterMs = init.retryAfterMs;
244
244
  }
@@ -255,8 +255,8 @@ var RealtimeConnectionError = class extends Error {
255
255
  };
256
256
  }
257
257
  };
258
- function isRetryableRealtimeRejection(code) {
259
- return code === "connection_limit_exceeded" || code === "server_at_capacity" || code === "rate_limited" || code === "auth_unavailable" || code === "internal_error";
258
+ function isRetryableRealtimeRejection(code, closeCode) {
259
+ return closeCode === 4429 || closeCode === 4500 || closeCode === 4503 || code === "connection_limit_exceeded" || code === "rate_limit_exceeded" || code === "server_at_capacity" || code === "rate_limited" || code === "auth_unavailable" || code === "internal_error" || code === "invalid_ws_ticket" || code === "ws_ticket_unavailable";
260
260
  }
261
261
  var RealtimeRequestError = class extends Error {
262
262
  constructor(init) {
@@ -3646,9 +3646,107 @@ var HttpClient = class {
3646
3646
  }
3647
3647
  };
3648
3648
 
3649
+ // src/core/ws-ticket.ts
3650
+ var WsTicketError = class extends Error {
3651
+ constructor(init) {
3652
+ super(
3653
+ init.message || init.code || `WebSocket ticket request failed (${init.status})`
3654
+ );
3655
+ this.name = "WsTicketError";
3656
+ this.status = init.status;
3657
+ this.code = init.code || "ws_ticket_rejected";
3658
+ this.retryAfterMs = init.retryAfterMs;
3659
+ }
3660
+ };
3661
+ var DEFAULT_TICKET_TIMEOUT_MS = 5e3;
3662
+ var unsupported = /* @__PURE__ */ new Set();
3663
+ function supportKey(url, kind) {
3664
+ try {
3665
+ const u = new URL(url);
3666
+ return `${u.origin}|${kind}`;
3667
+ } catch {
3668
+ return `${url}|${kind}`;
3669
+ }
3670
+ }
3671
+ async function fetchWsTicket(url, kind, credential, options = {}) {
3672
+ const key = supportKey(url, kind);
3673
+ if (unsupported.has(key)) return null;
3674
+ const headers = {};
3675
+ if (credential.kind === "access_token") {
3676
+ headers.Authorization = `Bearer ${credential.value}`;
3677
+ } else {
3678
+ headers["X-Public-Key"] = credential.value;
3679
+ }
3680
+ const { signal, cleanup } = createTimeoutController({
3681
+ timeout: Math.max(
3682
+ 1,
3683
+ Math.min(
3684
+ options.timeoutMs ?? DEFAULT_TICKET_TIMEOUT_MS,
3685
+ DEFAULT_TICKET_TIMEOUT_MS
3686
+ )
3687
+ ),
3688
+ signal: options.signal
3689
+ });
3690
+ let response;
3691
+ try {
3692
+ response = await withAbort(
3693
+ fetch(url, { method: "POST", headers, credentials: "omit", signal }),
3694
+ signal
3695
+ );
3696
+ } catch (err) {
3697
+ cleanup();
3698
+ if (options.signal?.aborted) throw err;
3699
+ return null;
3700
+ }
3701
+ try {
3702
+ if (response.ok) {
3703
+ const body2 = await response.json().catch(() => null);
3704
+ return typeof body2?.ticket === "string" && body2.ticket !== "" ? body2.ticket : null;
3705
+ }
3706
+ if (response.status === 404 || response.status === 405 || response.status === 501) {
3707
+ unsupported.add(key);
3708
+ return null;
3709
+ }
3710
+ if (response.status >= 500) return null;
3711
+ const body = await response.json().catch(() => null);
3712
+ const raw = typeof body?.error === "string" ? body.error : void 0;
3713
+ const isCode = raw !== void 0 && /^[a-z0-9_]+$/.test(raw);
3714
+ const code = isCode ? raw : statusCode(response.status);
3715
+ const message = typeof body?.message === "string" ? body.message : raw;
3716
+ const retryAfterMs = typeof body?.retry_after_ms === "number" && body.retry_after_ms > 0 ? body.retry_after_ms : void 0;
3717
+ throw new WsTicketError({
3718
+ status: response.status,
3719
+ code,
3720
+ message,
3721
+ retryAfterMs
3722
+ });
3723
+ } finally {
3724
+ cleanup();
3725
+ }
3726
+ }
3727
+ function statusCode(status) {
3728
+ switch (status) {
3729
+ case 401:
3730
+ return "unauthorized";
3731
+ case 402:
3732
+ return "quota_exceeded";
3733
+ case 403:
3734
+ return "forbidden";
3735
+ case 429:
3736
+ return "rate_limited";
3737
+ default:
3738
+ return "ws_ticket_rejected";
3739
+ }
3740
+ }
3741
+ function ticketUrl(base, path) {
3742
+ const httpBase = base.replace(/^ws(s?):\/\//, "http$1://").replace(/\/+$/, "");
3743
+ return `${httpBase}${path}`;
3744
+ }
3745
+
3649
3746
  // src/api/database.ts
3650
3747
  var REALTIME_TOKEN_MIN_VALIDITY_SEC = 600;
3651
3748
  var REALTIME_CONNECT_TOKEN_MIN_VALIDITY_SEC = 60;
3749
+ var REALTIME_CONNECT_TIMEOUT_MS = 15e3;
3652
3750
  var REALTIME_AUTH_REFRESH_RETRY_MS = 3e4;
3653
3751
  var REALTIME_CLOSING_NOTICE_CODES = /* @__PURE__ */ new Set([
3654
3752
  "TOKEN_EXPIRED",
@@ -3656,6 +3754,20 @@ var REALTIME_CLOSING_NOTICE_CODES = /* @__PURE__ */ new Set([
3656
3754
  ]);
3657
3755
  var CLOSE_CODE_TOKEN_EXPIRED = 4401;
3658
3756
  var CLOSE_CODE_MEMBER_REVOKED = 4403;
3757
+ function toRealtimeRejection(msg) {
3758
+ if (msg.type !== "error" || msg.request_id || typeof msg.close_code !== "number") {
3759
+ return null;
3760
+ }
3761
+ const details = msg.details;
3762
+ return new RealtimeConnectionError({
3763
+ code: typeof msg.code === "string" ? msg.code : void 0,
3764
+ message: typeof msg.message === "string" ? msg.message : void 0,
3765
+ closeCode: msg.close_code,
3766
+ retryable: typeof msg.retryable === "boolean" ? msg.retryable : void 0,
3767
+ details: details && typeof details === "object" && !Array.isArray(details) ? details : void 0,
3768
+ retryAfterMs: typeof msg.retry_after_ms === "number" && msg.retry_after_ms > 0 ? msg.retry_after_ms : void 0
3769
+ });
3770
+ }
3659
3771
  var DatabaseAPI = class {
3660
3772
  constructor(http) {
3661
3773
  this.realtimeWs = null;
@@ -4593,23 +4705,59 @@ var DatabaseAPI = class {
4593
4705
  );
4594
4706
  if (fresh) opts.accessToken = fresh;
4595
4707
  }
4596
- return this.doRealtimeConnect();
4708
+ let ticket;
4709
+ try {
4710
+ ticket = await this.realtimeTicket();
4711
+ } catch (e) {
4712
+ if (e instanceof WsTicketError) {
4713
+ const err = new RealtimeConnectionError({
4714
+ code: e.code,
4715
+ message: e.message,
4716
+ closeCode: 4e3 + e.status,
4717
+ retryAfterMs: e.retryAfterMs
4718
+ });
4719
+ this.handleRealtimeRejection(err);
4720
+ throw err;
4721
+ }
4722
+ throw e;
4723
+ }
4724
+ return this.doRealtimeConnect(ticket);
4725
+ }
4726
+ /**
4727
+ * 연결 시도 한 번에 쓸 1회용 티켓을 받는다(core/ws-ticket.ts). 브라우저 WebSocket 은 헤더를 못 붙여 예전에는 멤버 토큰을
4728
+ * URL(`?access_token=`)에 실었다 — 이제 헤더로 티켓을 받아 `?ticket=` 만 싣는다. 서버가 티켓을 모르거나(구버전) 잠시 못
4729
+ * 주면 null 이고 예전처럼 붙는다. cross-app OAuth 토큰도 같은 경로다(서버가 티켓에서 토큰을 되살려 같은 검증을 한다).
4730
+ */
4731
+ async realtimeTicket() {
4732
+ const opts = this.realtimeOptions;
4733
+ if (!opts?.accessToken) return null;
4734
+ const baseUrl = opts.dataServerUrl || this.http.getBaseUrl();
4735
+ return fetchWsTicket(
4736
+ ticketUrl(baseUrl, "/v1/database/realtime/ws-ticket"),
4737
+ "data.realtime",
4738
+ { kind: "access_token", value: opts.accessToken },
4739
+ { timeoutMs: REALTIME_CONNECT_TIMEOUT_MS }
4740
+ );
4597
4741
  }
4598
- doRealtimeConnect() {
4742
+ doRealtimeConnect(ticket = null) {
4599
4743
  if (!this.realtimeOptions)
4600
4744
  return Promise.reject(new Error("No realtime options"));
4601
4745
  this.setRealtimeState("connecting");
4602
4746
  const baseUrl = this.realtimeOptions.dataServerUrl || this.http.getBaseUrl();
4603
4747
  const wsUrl = baseUrl.replace(/^http/, "ws");
4604
- let url = `${wsUrl}/v1/database/realtime/ws?access_token=${encodeURIComponent(this.realtimeOptions.accessToken)}`;
4748
+ let url = ticket ? `${wsUrl}/v1/database/realtime/ws?ticket=${encodeURIComponent(ticket)}` : `${wsUrl}/v1/database/realtime/ws?access_token=${encodeURIComponent(this.realtimeOptions.accessToken)}`;
4605
4749
  this.realtimeTokenRefresh = this.http.canRefreshMemberToken(
4606
4750
  this.realtimeOptions.accessToken
4607
4751
  );
4608
4752
  if (this.realtimeTokenRefresh) url += "&token_refresh=1";
4753
+ url += "&reject_mode=close";
4609
4754
  return new Promise((resolve, reject) => {
4610
4755
  try {
4611
- this.realtimeWs = new WebSocket(url);
4756
+ const ws = new WebSocket(url);
4757
+ this.realtimeWs = ws;
4612
4758
  let settled = false;
4759
+ let accepted = false;
4760
+ let rejection = null;
4613
4761
  const connectTimeout = setTimeout(() => {
4614
4762
  if (!settled) {
4615
4763
  settled = true;
@@ -4620,8 +4768,9 @@ var DatabaseAPI = class {
4620
4768
  this.setRealtimeState("disconnected");
4621
4769
  reject(new Error("Connection timeout"));
4622
4770
  }
4623
- }, 15e3);
4624
- this.realtimeWs.onopen = () => {
4771
+ }, REALTIME_CONNECT_TIMEOUT_MS);
4772
+ const accept = () => {
4773
+ accepted = true;
4625
4774
  if (!settled) {
4626
4775
  settled = true;
4627
4776
  clearTimeout(connectTimeout);
@@ -4634,15 +4783,28 @@ var DatabaseAPI = class {
4634
4783
  this.resubscribeAll();
4635
4784
  resolve();
4636
4785
  };
4637
- this.realtimeWs.onmessage = (event) => {
4786
+ ws.onopen = () => {
4787
+ ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
4788
+ };
4789
+ ws.onmessage = (event) => {
4790
+ let msg;
4638
4791
  try {
4639
- const msg = JSON.parse(event.data);
4640
- this.handleRealtimeMessage(msg);
4792
+ msg = JSON.parse(event.data);
4641
4793
  } catch {
4642
4794
  this.debugLog("Failed to parse realtime message");
4795
+ return;
4643
4796
  }
4797
+ if (!accepted) {
4798
+ const rejected = toRealtimeRejection(msg);
4799
+ if (rejected) {
4800
+ rejection = rejected;
4801
+ return;
4802
+ }
4803
+ accept();
4804
+ }
4805
+ this.handleRealtimeMessage(msg);
4644
4806
  };
4645
- this.realtimeWs.onclose = (event) => {
4807
+ ws.onclose = (event) => {
4646
4808
  this.debugLog(
4647
4809
  `Database realtime disconnected (code=${event?.code}, reason=${event?.reason})`
4648
4810
  );
@@ -4651,12 +4813,25 @@ var DatabaseAPI = class {
4651
4813
  this.clearRealtimeAuthRetry();
4652
4814
  const notice = this.realtimeCloseNotice;
4653
4815
  this.realtimeCloseNotice = null;
4816
+ const code = event?.code;
4817
+ if (!accepted && code >= 4e3 && code <= 4999) {
4818
+ const err = rejection ?? new RealtimeConnectionError({
4819
+ code: event.reason || void 0,
4820
+ closeCode: code
4821
+ });
4822
+ if (!settled) {
4823
+ settled = true;
4824
+ clearTimeout(connectTimeout);
4825
+ reject(err);
4826
+ }
4827
+ this.handleRealtimeRejection(err);
4828
+ return;
4829
+ }
4654
4830
  if (!settled) {
4655
4831
  settled = true;
4656
4832
  clearTimeout(connectTimeout);
4657
4833
  reject(new Error("Connection closed during handshake"));
4658
4834
  }
4659
- const code = event?.code;
4660
4835
  if (this.realtimeOptions && this.realtimeState !== "disconnected" && (code === CLOSE_CODE_MEMBER_REVOKED || code === CLOSE_CODE_TOKEN_EXPIRED && !this.http.canRefreshMemberToken(
4661
4836
  this.realtimeOptions.accessToken
4662
4837
  ))) {
@@ -4674,7 +4849,7 @@ var DatabaseAPI = class {
4674
4849
  this.attemptRealtimeReconnect();
4675
4850
  }
4676
4851
  };
4677
- this.realtimeWs.onerror = () => {
4852
+ ws.onerror = () => {
4678
4853
  this.debugLog("Database realtime error");
4679
4854
  this.realtimeOnError?.(new Error("WebSocket connection error"));
4680
4855
  };
@@ -4684,6 +4859,21 @@ var DatabaseAPI = class {
4684
4859
  }
4685
4860
  });
4686
4861
  }
4862
+ /**
4863
+ * 서버가 연결을 거부했다(티켓 발급 거부, 또는 reject_mode=close 로 받은 업그레이드 거부). cb.realtime 과 같은 규칙이다 —
4864
+ * 다시 해서 될 거부(`retryable`: 일시 장애, 속도 제한, 동시접속 한도, 만료된 티켓)는 서버가 알려 준 시각(`retryAfterMs`)
4865
+ * 이후로 재연결을 이어 가고, 아니면(자격증명, 멤버 폐기, 할당량) 멈추고 `onRealtimeError` 로 알린다. 재시도를 다 써도
4866
+ * 거부되면 마지막 거부를 알린다.
4867
+ */
4868
+ handleRealtimeRejection(err) {
4869
+ if (!this.realtimeOptions || this.realtimeState === "disconnected") return;
4870
+ if (err.retryable) {
4871
+ this.attemptRealtimeReconnect(err);
4872
+ return;
4873
+ }
4874
+ this.setRealtimeState("disconnected");
4875
+ this.realtimeOnError?.(err);
4876
+ }
4687
4877
  /**
4688
4878
  * 서버에 subscribe 메시지를 보내고 서버 subscription_id를 반환하는 Promise
4689
4879
  */
@@ -4856,21 +5046,25 @@ var DatabaseAPI = class {
4856
5046
  break;
4857
5047
  }
4858
5048
  }
4859
- attemptRealtimeReconnect() {
5049
+ /**
5050
+ * 지수 백오프로 다시 붙는다. cause 는 이번 시도를 끝낸 서버의 거부다 — 알려 준 재시도 시각(`retryAfterMs`)보다 일찍 다시
5051
+ * 하지 않고, 재시도를 다 쓰면 일반 오류 대신 그 거부를 알린다.
5052
+ */
5053
+ attemptRealtimeReconnect(cause) {
4860
5054
  const maxRetries = this.realtimeOptions?.maxRetries ?? 5;
4861
5055
  const retryInterval = this.realtimeOptions?.retryInterval ?? 1e3;
4862
5056
  if (this.realtimeRetryCount >= maxRetries) {
4863
5057
  this.setRealtimeState("disconnected");
4864
5058
  this.realtimeOnError?.(
4865
- new Error("Realtime connection lost. Max retries exceeded.")
5059
+ cause ?? new Error("Realtime connection lost. Max retries exceeded.")
4866
5060
  );
4867
5061
  return;
4868
5062
  }
4869
5063
  this.setRealtimeState("connecting");
4870
5064
  this.realtimeRetryCount++;
4871
- const delay = Math.min(
4872
- retryInterval * 2 ** (this.realtimeRetryCount - 1),
4873
- 3e4
5065
+ const delay = Math.max(
5066
+ Math.min(retryInterval * 2 ** (this.realtimeRetryCount - 1), 3e4),
5067
+ cause?.retryAfterMs ?? 0
4874
5068
  );
4875
5069
  this.debugLog(
4876
5070
  `Reconnecting in ${delay}ms (attempt ${this.realtimeRetryCount}/${maxRetries})`
@@ -5161,7 +5355,7 @@ var EndpointAPI = class {
5161
5355
  );
5162
5356
  }
5163
5357
  const baseUrl = this.http.getBaseUrl().replace(/\/+$/, "");
5164
- const ticketUrl = `${baseUrl}/v1/proxy/${encodeURIComponent(label)}/ws-ticket`;
5358
+ const ticketUrl2 = `${baseUrl}/v1/proxy/${encodeURIComponent(label)}/ws-ticket`;
5165
5359
  const ticketHeaders = new Headers();
5166
5360
  const pk = this.http.getPublicKey();
5167
5361
  if (!pk) {
@@ -5170,7 +5364,7 @@ var EndpointAPI = class {
5170
5364
  );
5171
5365
  }
5172
5366
  ticketHeaders.set("X-Public-Key", pk);
5173
- const ticketRes = await fetch(ticketUrl, {
5367
+ const ticketRes = await fetch(ticketUrl2, {
5174
5368
  method: "POST",
5175
5369
  headers: ticketHeaders,
5176
5370
  signal: opts.signal
@@ -5868,20 +6062,54 @@ var GameRoom = class {
5868
6062
  * 게임 서버에 연결
5869
6063
  */
5870
6064
  connect(roomId) {
5871
- return new Promise((resolve, reject) => {
5872
- if (this.ws?.readyState === WebSocket.OPEN) {
5873
- resolve();
5874
- return;
5875
- }
5876
- if (!this.config.appId) {
5877
- reject(
5878
- new Error(
5879
- "cb.game: appId is required to connect. Pass it to `new ConnectBase({ appId })` or per-client via `cb.game.createClient({ appId, clientId })`."
5880
- )
5881
- );
5882
- return;
6065
+ if (this.ws?.readyState === WebSocket.OPEN) {
6066
+ return Promise.resolve();
6067
+ }
6068
+ if (!this.config.appId) {
6069
+ return Promise.reject(
6070
+ new Error(
6071
+ "cb.game: appId is required to connect. Pass it to `new ConnectBase({ appId })` or per-client via `cb.game.createClient({ appId, clientId })`."
6072
+ )
6073
+ );
6074
+ }
6075
+ if (!this.config.clientId) {
6076
+ return Promise.reject(
6077
+ new Error(
6078
+ "cb.game: clientId is required to build a game connection URL. Pass it via `cb.game.createClient({ appId, clientId })`."
6079
+ )
6080
+ );
6081
+ }
6082
+ return this.connectionTicket().then(
6083
+ (ticket) => this.openSocket(roomId, ticket)
6084
+ );
6085
+ }
6086
+ /**
6087
+ * 연결 시도 한 번에 쓸 1회용 티켓을 받는다(core/ws-ticket.ts). 브라우저 WebSocket 은 헤더를 못 붙여 예전에는 멤버
6088
+ * 토큰(`?token=`)과 공개 키(`?public_key=`)를 URL 에 실었다 — 이제 헤더로 티켓을 받아 `?ticket=` 만 싣는다. 서버가
6089
+ * 티켓을 모르거나(구버전) 잠시 못 주면 null 이고 예전처럼 붙는다. 발급이 자격증명을 거부하면 {@link GameError} 로 던진다.
6090
+ */
6091
+ async connectionTicket() {
6092
+ const { accessToken, publicKey, appId, gameServerUrl } = this.config;
6093
+ if (!accessToken && !publicKey || !appId || !gameServerUrl) return null;
6094
+ try {
6095
+ return await fetchWsTicket(
6096
+ ticketUrl(
6097
+ gameServerUrl,
6098
+ `/v1/game/${encodeURIComponent(appId)}/ws-ticket`
6099
+ ),
6100
+ "game.ws",
6101
+ accessToken ? { kind: "access_token", value: accessToken } : { kind: "public_key", value: publicKey }
6102
+ );
6103
+ } catch (e) {
6104
+ if (e instanceof WsTicketError) {
6105
+ throw new GameError({ code: e.code, message: e.message });
5883
6106
  }
5884
- const url = this.buildConnectionUrl(roomId);
6107
+ throw e;
6108
+ }
6109
+ }
6110
+ openSocket(roomId, ticket) {
6111
+ return new Promise((resolve, reject) => {
6112
+ const url = this.buildConnectionUrl(roomId, ticket);
5885
6113
  this.ws = new WebSocket(url);
5886
6114
  const onOpen = () => {
5887
6115
  this._isConnected = true;
@@ -6137,7 +6365,7 @@ var GameRoom = class {
6137
6365
  });
6138
6366
  }
6139
6367
  // Private methods
6140
- buildConnectionUrl(roomId) {
6368
+ buildConnectionUrl(roomId, ticket) {
6141
6369
  const baseUrl = this.config.gameServerUrl;
6142
6370
  const wsUrl = baseUrl.replace(/^http/, "ws");
6143
6371
  const params = new URLSearchParams();
@@ -6150,11 +6378,15 @@ var GameRoom = class {
6150
6378
  if (roomId) {
6151
6379
  params.set("room_id", roomId);
6152
6380
  }
6153
- if (this.config.publicKey) {
6154
- params.set("public_key", this.config.publicKey);
6155
- }
6156
- if (this.config.accessToken) {
6157
- params.set("token", this.config.accessToken);
6381
+ if (ticket) {
6382
+ params.set("ticket", ticket);
6383
+ } else {
6384
+ if (this.config.publicKey) {
6385
+ params.set("public_key", this.config.publicKey);
6386
+ }
6387
+ if (this.config.accessToken) {
6388
+ params.set("token", this.config.accessToken);
6389
+ }
6158
6390
  }
6159
6391
  if (!this.config.appId) {
6160
6392
  throw new Error(
@@ -10011,7 +10243,14 @@ var ConnectCancelledError = class extends Error {
10011
10243
  }
10012
10244
  };
10013
10245
  var MAX_HANDSHAKE_RETRIES = 2;
10014
- var RETRY_IN_CONNECT_CODES = /* @__PURE__ */ new Set(["rate_limited", "auth_unavailable"]);
10246
+ var RETRY_IN_CONNECT_CODES = /* @__PURE__ */ new Set([
10247
+ "rate_limited",
10248
+ "auth_unavailable",
10249
+ // 1회용 티켓이 업그레이드에서 거부됐다(만료, 이미 쓰임, 저장소 일시 장애). 다음 시도는 새 티켓을 받거나, 저장소가
10250
+ // 없으면 예전 방식으로 붙는다(core/ws-ticket.ts).
10251
+ "invalid_ws_ticket",
10252
+ "ws_ticket_unavailable"
10253
+ ]);
10015
10254
  var CONNECTION_TOKEN_MIN_VALIDITY_SEC = 600;
10016
10255
  var CONNECT_TOKEN_MIN_VALIDITY_SEC = 60;
10017
10256
  var AUTH_REFRESH_RETRY_MS = 3e4;
@@ -11126,7 +11365,10 @@ var RealtimeAPI = class {
11126
11365
  for (let attempt = 1; ; attempt++) {
11127
11366
  try {
11128
11367
  await this.prepareConnectToken();
11129
- await this.doConnect(Math.max(deadline - Date.now(), 1));
11368
+ const ticket = await this.connectTicket(
11369
+ Math.max(deadline - Date.now(), 1)
11370
+ );
11371
+ await this.doConnect(Math.max(deadline - Date.now(), 1), ticket);
11130
11372
  return;
11131
11373
  } catch (e) {
11132
11374
  let wait = null;
@@ -11169,7 +11411,39 @@ var RealtimeAPI = class {
11169
11411
  this.initialConnecting = false;
11170
11412
  }
11171
11413
  }
11172
- async doConnect(timeoutMs = this.options.timeout) {
11414
+ /**
11415
+ * 연결 시도 한 번에 쓸 1회용 티켓을 받는다(core/ws-ticket.ts). 브라우저 WebSocket 은 헤더를 못 붙여 예전에는 멤버
11416
+ * 토큰과 공개 키를 URL 에 실었다 — 이제 헤더로 티켓을 받아 `?ticket=` 만 싣는다. 티켓은 한 번만 쓰이므로 시도마다
11417
+ * 새로 받는다.
11418
+ *
11419
+ * 서버가 티켓을 모르거나(구버전) 잠시 못 주면 null 이고, doConnect 가 예전처럼 자격증명을 URL 에 싣는다. 발급이
11420
+ * 자격증명을 거부하면 업그레이드 거부와 같은 {@link RealtimeConnectionError} 로 던진다 — 재시도 규칙이 같다.
11421
+ */
11422
+ async connectTicket(timeoutMs) {
11423
+ const accessToken = this.options.accessToken;
11424
+ const publicKey = accessToken ? void 0 : this.http.getPublicKey();
11425
+ if (!accessToken && !publicKey) return null;
11426
+ try {
11427
+ return await fetchWsTicket(
11428
+ ticketUrl(this.socketUrl, "/v1/realtime/ws-ticket"),
11429
+ "socket.realtime",
11430
+ accessToken ? { kind: "access_token", value: accessToken } : { kind: "public_key", value: publicKey },
11431
+ { timeoutMs }
11432
+ );
11433
+ } catch (e) {
11434
+ if (e instanceof WsTicketError) {
11435
+ this.log(`WebSocket ticket rejected: ${e.code} (${e.status})`);
11436
+ throw new RealtimeConnectionError({
11437
+ code: e.code,
11438
+ message: e.message,
11439
+ closeCode: 4e3 + e.status,
11440
+ retryAfterMs: e.retryAfterMs
11441
+ });
11442
+ }
11443
+ throw e;
11444
+ }
11445
+ }
11446
+ async doConnect(timeoutMs = this.options.timeout, ticket = null) {
11173
11447
  return new Promise((resolve, reject) => {
11174
11448
  this.state = "connecting";
11175
11449
  this.notifyStateChange();
@@ -11178,9 +11452,10 @@ var RealtimeAPI = class {
11178
11452
  const usingDirect = !!directWsBase;
11179
11453
  const wsUrl = (directWsBase ?? this.socketUrl).replace(/^http/, "ws");
11180
11454
  let url;
11455
+ const auth = ticket ? `ticket=${encodeURIComponent(ticket)}` : void 0;
11181
11456
  this.tokenRefreshNegotiated = false;
11182
11457
  if (this.options.accessToken) {
11183
- url = `${wsUrl}/v1/realtime/auth?access_token=${encodeURIComponent(this.options.accessToken)}&client_id=${this.clientId}`;
11458
+ url = `${wsUrl}/v1/realtime/auth?${auth ?? `access_token=${encodeURIComponent(this.options.accessToken)}`}&client_id=${this.clientId}`;
11184
11459
  if (this.http.canRefreshMemberToken(this.options.accessToken)) {
11185
11460
  this.tokenRefreshNegotiated = true;
11186
11461
  url += "&token_refresh=1";
@@ -11196,7 +11471,7 @@ var RealtimeAPI = class {
11196
11471
  reject(error);
11197
11472
  return;
11198
11473
  }
11199
- url = `${wsUrl}/v1/realtime/auth?public_key=${encodeURIComponent(publicKey)}&client_id=${this.clientId}`;
11474
+ url = `${wsUrl}/v1/realtime/auth?${auth ?? `public_key=${encodeURIComponent(publicKey)}`}&client_id=${this.clientId}`;
11200
11475
  this.log("Using API Key authentication");
11201
11476
  }
11202
11477
  if (this.userId) {
@@ -11676,7 +11951,9 @@ var RealtimeAPI = class {
11676
11951
  try {
11677
11952
  await this.prepareConnectToken();
11678
11953
  if (this.state !== "reconnecting") return;
11679
- await this.doConnect();
11954
+ const ticket = await this.connectTicket(this.options.timeout);
11955
+ if (this.state !== "reconnecting") return;
11956
+ await this.doConnect(void 0, ticket);
11680
11957
  this.log("Reconnected successfully, restoring subscriptions...");
11681
11958
  const snapshot = this.pendingRestore;
11682
11959
  this.pendingRestore = null;
@@ -13279,8 +13556,8 @@ var SupportAPI = class {
13279
13556
  }
13280
13557
  };
13281
13558
  function detectSdkVersion() {
13282
- if ("6.13.0") {
13283
- return "6.13.0";
13559
+ if ("6.15.0") {
13560
+ return "6.15.0";
13284
13561
  }
13285
13562
  return "unknown";
13286
13563
  }
@@ -14249,9 +14526,35 @@ var WebRTCAPI = class _WebRTCAPI {
14249
14526
  }
14250
14527
  return this.connectWebSocket();
14251
14528
  }
14252
- connectWebSocket() {
14529
+ async connectWebSocket() {
14530
+ const before = this.wsGeneration;
14531
+ const ticket = await this.signalingTicket();
14532
+ if (this.wsGeneration !== before) {
14533
+ throw new Error("\uC5F0\uACB0\uC774 \uCDE8\uC18C\uB418\uC5C8\uC2B5\uB2C8\uB2E4");
14534
+ }
14535
+ return this.openSignalingSocket(ticket);
14536
+ }
14537
+ /**
14538
+ * 연결 시도 한 번에 쓸 1회용 티켓을 받는다(core/ws-ticket.ts). 브라우저 WebSocket 은 헤더를 못 붙여 예전에는 멤버 토큰
14539
+ * (`?access_token=`)과 공개 키(`?public_key=`)를 URL 에 실었다 — 이제 헤더로 티켓을 받아 `?ticket=` 만 싣는다. 서버가
14540
+ * 티켓을 모르거나(구버전) 잠시 못 주면 null 이고 예전처럼 붙는다. 발급이 자격증명을 거부하면 그 이유로 던진다.
14541
+ */
14542
+ async signalingTicket() {
14543
+ const accessToken = this.http.getAccessToken();
14544
+ const publicKey = accessToken ? void 0 : this.http.getPublicKey();
14545
+ if (!accessToken && !publicKey || !this.appId) return null;
14546
+ return fetchWsTicket(
14547
+ ticketUrl(
14548
+ this.webrtcUrl,
14549
+ `/v1/apps/${encodeURIComponent(this.appId)}/signaling/ws-ticket`
14550
+ ),
14551
+ "webrtc.signaling",
14552
+ accessToken ? { kind: "access_token", value: accessToken } : { kind: "public_key", value: publicKey }
14553
+ );
14554
+ }
14555
+ openSignalingSocket(ticket) {
14253
14556
  return new Promise((resolve, reject) => {
14254
- const wsUrl = this.buildWebSocketUrl();
14557
+ const wsUrl = this.buildWebSocketUrl(ticket);
14255
14558
  const generation = ++this.wsGeneration;
14256
14559
  const ws = new WebSocket(wsUrl);
14257
14560
  this.ws = ws;
@@ -14320,12 +14623,14 @@ var WebRTCAPI = class _WebRTCAPI {
14320
14623
  ws.onerror = null;
14321
14624
  ws.onclose = null;
14322
14625
  }
14323
- buildWebSocketUrl() {
14626
+ buildWebSocketUrl(ticket = null) {
14324
14627
  const wsBase = this.webrtcUrl.replace("https://", "wss://").replace("http://", "ws://");
14325
14628
  const publicKey = this.http.getPublicKey();
14326
14629
  const accessToken = this.http.getAccessToken();
14327
14630
  let authParam = "";
14328
- if (accessToken) {
14631
+ if (ticket) {
14632
+ authParam = `ticket=${encodeURIComponent(ticket)}`;
14633
+ } else if (accessToken) {
14329
14634
  authParam = `access_token=${encodeURIComponent(accessToken)}`;
14330
14635
  } else if (publicKey) {
14331
14636
  authParam = `public_key=${encodeURIComponent(publicKey)}`;
@@ -14983,11 +15288,35 @@ var WebSocketTransport = class {
14983
15288
  this.onClose = onClose;
14984
15289
  this.onError = onError;
14985
15290
  }
14986
- connect() {
15291
+ async connect() {
15292
+ const ticket = await this.connectionTicket();
15293
+ return this.openSocket(ticket);
15294
+ }
15295
+ /**
15296
+ * 연결 시도 한 번에 쓸 1회용 티켓(core/ws-ticket.ts). 서버가 티켓을 모르거나(구버전) 잠시 못 주면 null 이고 예전처럼
15297
+ * 자격증명을 URL 에 싣는다. 발급이 자격증명을 거부하면 {@link GameError} 로 던진다.
15298
+ */
15299
+ async connectionTicket() {
15300
+ const { accessToken, publicKey, appId } = this.config;
15301
+ if (!accessToken && !publicKey || !appId) return null;
15302
+ const baseUrl = this.config.gameServerUrl || "wss://game.connectbase.world";
15303
+ try {
15304
+ return await fetchWsTicket(
15305
+ ticketUrl(baseUrl, `/v1/game/${encodeURIComponent(appId)}/ws-ticket`),
15306
+ "game.ws",
15307
+ accessToken ? { kind: "access_token", value: accessToken } : { kind: "public_key", value: publicKey }
15308
+ );
15309
+ } catch (e) {
15310
+ if (e instanceof WsTicketError) {
15311
+ throw new GameError({ code: e.code, message: e.message });
15312
+ }
15313
+ throw e;
15314
+ }
15315
+ }
15316
+ openSocket(ticket) {
14987
15317
  return new Promise((resolve, reject) => {
14988
- const url = this.buildUrl();
14989
15318
  try {
14990
- this.ws = new WebSocket(url);
15319
+ this.ws = new WebSocket(this.buildUrl(ticket));
14991
15320
  this.ws.binaryType = "arraybuffer";
14992
15321
  } catch (error) {
14993
15322
  reject(error);
@@ -15017,16 +15346,20 @@ var WebSocketTransport = class {
15017
15346
  this.ws.addEventListener("message", onMessage);
15018
15347
  });
15019
15348
  }
15020
- buildUrl() {
15349
+ buildUrl(ticket = null) {
15021
15350
  const baseUrl = this.config.gameServerUrl || "wss://game.connectbase.world";
15022
15351
  const wsUrl = baseUrl.replace(/^http/, "ws");
15023
15352
  const params = new URLSearchParams();
15024
15353
  params.set("client_id", requireClientId(this.config.clientId));
15025
- if (this.config.publicKey) {
15026
- params.set("public_key", this.config.publicKey);
15027
- }
15028
- if (this.config.accessToken) {
15029
- params.set("token", this.config.accessToken);
15354
+ if (ticket) {
15355
+ params.set("ticket", ticket);
15356
+ } else {
15357
+ if (this.config.publicKey) {
15358
+ params.set("public_key", this.config.publicKey);
15359
+ }
15360
+ if (this.config.accessToken) {
15361
+ params.set("token", this.config.accessToken);
15362
+ }
15030
15363
  }
15031
15364
  if (!this.config.appId) {
15032
15365
  throw new Error(
@@ -15705,6 +16038,7 @@ export {
15705
16038
  RolesAPI,
15706
16039
  SessionManager,
15707
16040
  VideoProcessingError,
16041
+ WsTicketError,
15708
16042
  index_default as default,
15709
16043
  detectInAppBrowser,
15710
16044
  escapeToExternalBrowser,