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.js CHANGED
@@ -41,6 +41,7 @@ __export(index_exports, {
41
41
  RolesAPI: () => RolesAPI,
42
42
  SessionManager: () => SessionManager,
43
43
  VideoProcessingError: () => VideoProcessingError,
44
+ WsTicketError: () => WsTicketError,
44
45
  default: () => index_default,
45
46
  detectInAppBrowser: () => detectInAppBrowser,
46
47
  escapeToExternalBrowser: () => escapeToExternalBrowser,
@@ -170,9 +171,9 @@ var AdsAPI = class {
170
171
 
171
172
  // src/types/error.ts
172
173
  var ApiError = class extends Error {
173
- constructor(statusCode, message, code, details) {
174
+ constructor(statusCode2, message, code, details) {
174
175
  super(message);
175
- this.statusCode = statusCode;
176
+ this.statusCode = statusCode2;
176
177
  this.name = "ApiError";
177
178
  this.code = code;
178
179
  this.details = details;
@@ -290,7 +291,7 @@ var RealtimeConnectionError = class extends Error {
290
291
  this.name = "RealtimeConnectionError";
291
292
  this.code = init.code || "unknown";
292
293
  this.closeCode = init.closeCode;
293
- this.retryable = init.retryable ?? isRetryableRealtimeRejection(this.code);
294
+ this.retryable = init.retryable ?? isRetryableRealtimeRejection(this.code, init.closeCode);
294
295
  this.details = init.details;
295
296
  this.retryAfterMs = init.retryAfterMs;
296
297
  }
@@ -307,8 +308,8 @@ var RealtimeConnectionError = class extends Error {
307
308
  };
308
309
  }
309
310
  };
310
- function isRetryableRealtimeRejection(code) {
311
- return code === "connection_limit_exceeded" || code === "server_at_capacity" || code === "rate_limited" || code === "auth_unavailable" || code === "internal_error";
311
+ function isRetryableRealtimeRejection(code, closeCode) {
312
+ 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";
312
313
  }
313
314
  var RealtimeRequestError = class extends Error {
314
315
  constructor(init) {
@@ -3698,9 +3699,107 @@ var HttpClient = class {
3698
3699
  }
3699
3700
  };
3700
3701
 
3702
+ // src/core/ws-ticket.ts
3703
+ var WsTicketError = class extends Error {
3704
+ constructor(init) {
3705
+ super(
3706
+ init.message || init.code || `WebSocket ticket request failed (${init.status})`
3707
+ );
3708
+ this.name = "WsTicketError";
3709
+ this.status = init.status;
3710
+ this.code = init.code || "ws_ticket_rejected";
3711
+ this.retryAfterMs = init.retryAfterMs;
3712
+ }
3713
+ };
3714
+ var DEFAULT_TICKET_TIMEOUT_MS = 5e3;
3715
+ var unsupported = /* @__PURE__ */ new Set();
3716
+ function supportKey(url, kind) {
3717
+ try {
3718
+ const u = new URL(url);
3719
+ return `${u.origin}|${kind}`;
3720
+ } catch {
3721
+ return `${url}|${kind}`;
3722
+ }
3723
+ }
3724
+ async function fetchWsTicket(url, kind, credential, options = {}) {
3725
+ const key = supportKey(url, kind);
3726
+ if (unsupported.has(key)) return null;
3727
+ const headers = {};
3728
+ if (credential.kind === "access_token") {
3729
+ headers.Authorization = `Bearer ${credential.value}`;
3730
+ } else {
3731
+ headers["X-Public-Key"] = credential.value;
3732
+ }
3733
+ const { signal, cleanup } = createTimeoutController({
3734
+ timeout: Math.max(
3735
+ 1,
3736
+ Math.min(
3737
+ options.timeoutMs ?? DEFAULT_TICKET_TIMEOUT_MS,
3738
+ DEFAULT_TICKET_TIMEOUT_MS
3739
+ )
3740
+ ),
3741
+ signal: options.signal
3742
+ });
3743
+ let response;
3744
+ try {
3745
+ response = await withAbort(
3746
+ fetch(url, { method: "POST", headers, credentials: "omit", signal }),
3747
+ signal
3748
+ );
3749
+ } catch (err) {
3750
+ cleanup();
3751
+ if (options.signal?.aborted) throw err;
3752
+ return null;
3753
+ }
3754
+ try {
3755
+ if (response.ok) {
3756
+ const body2 = await response.json().catch(() => null);
3757
+ return typeof body2?.ticket === "string" && body2.ticket !== "" ? body2.ticket : null;
3758
+ }
3759
+ if (response.status === 404 || response.status === 405 || response.status === 501) {
3760
+ unsupported.add(key);
3761
+ return null;
3762
+ }
3763
+ if (response.status >= 500) return null;
3764
+ const body = await response.json().catch(() => null);
3765
+ const raw = typeof body?.error === "string" ? body.error : void 0;
3766
+ const isCode = raw !== void 0 && /^[a-z0-9_]+$/.test(raw);
3767
+ const code = isCode ? raw : statusCode(response.status);
3768
+ const message = typeof body?.message === "string" ? body.message : raw;
3769
+ const retryAfterMs = typeof body?.retry_after_ms === "number" && body.retry_after_ms > 0 ? body.retry_after_ms : void 0;
3770
+ throw new WsTicketError({
3771
+ status: response.status,
3772
+ code,
3773
+ message,
3774
+ retryAfterMs
3775
+ });
3776
+ } finally {
3777
+ cleanup();
3778
+ }
3779
+ }
3780
+ function statusCode(status) {
3781
+ switch (status) {
3782
+ case 401:
3783
+ return "unauthorized";
3784
+ case 402:
3785
+ return "quota_exceeded";
3786
+ case 403:
3787
+ return "forbidden";
3788
+ case 429:
3789
+ return "rate_limited";
3790
+ default:
3791
+ return "ws_ticket_rejected";
3792
+ }
3793
+ }
3794
+ function ticketUrl(base, path) {
3795
+ const httpBase = base.replace(/^ws(s?):\/\//, "http$1://").replace(/\/+$/, "");
3796
+ return `${httpBase}${path}`;
3797
+ }
3798
+
3701
3799
  // src/api/database.ts
3702
3800
  var REALTIME_TOKEN_MIN_VALIDITY_SEC = 600;
3703
3801
  var REALTIME_CONNECT_TOKEN_MIN_VALIDITY_SEC = 60;
3802
+ var REALTIME_CONNECT_TIMEOUT_MS = 15e3;
3704
3803
  var REALTIME_AUTH_REFRESH_RETRY_MS = 3e4;
3705
3804
  var REALTIME_CLOSING_NOTICE_CODES = /* @__PURE__ */ new Set([
3706
3805
  "TOKEN_EXPIRED",
@@ -3708,6 +3807,20 @@ var REALTIME_CLOSING_NOTICE_CODES = /* @__PURE__ */ new Set([
3708
3807
  ]);
3709
3808
  var CLOSE_CODE_TOKEN_EXPIRED = 4401;
3710
3809
  var CLOSE_CODE_MEMBER_REVOKED = 4403;
3810
+ function toRealtimeRejection(msg) {
3811
+ if (msg.type !== "error" || msg.request_id || typeof msg.close_code !== "number") {
3812
+ return null;
3813
+ }
3814
+ const details = msg.details;
3815
+ return new RealtimeConnectionError({
3816
+ code: typeof msg.code === "string" ? msg.code : void 0,
3817
+ message: typeof msg.message === "string" ? msg.message : void 0,
3818
+ closeCode: msg.close_code,
3819
+ retryable: typeof msg.retryable === "boolean" ? msg.retryable : void 0,
3820
+ details: details && typeof details === "object" && !Array.isArray(details) ? details : void 0,
3821
+ retryAfterMs: typeof msg.retry_after_ms === "number" && msg.retry_after_ms > 0 ? msg.retry_after_ms : void 0
3822
+ });
3823
+ }
3711
3824
  var DatabaseAPI = class {
3712
3825
  constructor(http) {
3713
3826
  this.realtimeWs = null;
@@ -4645,23 +4758,59 @@ var DatabaseAPI = class {
4645
4758
  );
4646
4759
  if (fresh) opts.accessToken = fresh;
4647
4760
  }
4648
- return this.doRealtimeConnect();
4761
+ let ticket;
4762
+ try {
4763
+ ticket = await this.realtimeTicket();
4764
+ } catch (e) {
4765
+ if (e instanceof WsTicketError) {
4766
+ const err = new RealtimeConnectionError({
4767
+ code: e.code,
4768
+ message: e.message,
4769
+ closeCode: 4e3 + e.status,
4770
+ retryAfterMs: e.retryAfterMs
4771
+ });
4772
+ this.handleRealtimeRejection(err);
4773
+ throw err;
4774
+ }
4775
+ throw e;
4776
+ }
4777
+ return this.doRealtimeConnect(ticket);
4778
+ }
4779
+ /**
4780
+ * 연결 시도 한 번에 쓸 1회용 티켓을 받는다(core/ws-ticket.ts). 브라우저 WebSocket 은 헤더를 못 붙여 예전에는 멤버 토큰을
4781
+ * URL(`?access_token=`)에 실었다 — 이제 헤더로 티켓을 받아 `?ticket=` 만 싣는다. 서버가 티켓을 모르거나(구버전) 잠시 못
4782
+ * 주면 null 이고 예전처럼 붙는다. cross-app OAuth 토큰도 같은 경로다(서버가 티켓에서 토큰을 되살려 같은 검증을 한다).
4783
+ */
4784
+ async realtimeTicket() {
4785
+ const opts = this.realtimeOptions;
4786
+ if (!opts?.accessToken) return null;
4787
+ const baseUrl = opts.dataServerUrl || this.http.getBaseUrl();
4788
+ return fetchWsTicket(
4789
+ ticketUrl(baseUrl, "/v1/database/realtime/ws-ticket"),
4790
+ "data.realtime",
4791
+ { kind: "access_token", value: opts.accessToken },
4792
+ { timeoutMs: REALTIME_CONNECT_TIMEOUT_MS }
4793
+ );
4649
4794
  }
4650
- doRealtimeConnect() {
4795
+ doRealtimeConnect(ticket = null) {
4651
4796
  if (!this.realtimeOptions)
4652
4797
  return Promise.reject(new Error("No realtime options"));
4653
4798
  this.setRealtimeState("connecting");
4654
4799
  const baseUrl = this.realtimeOptions.dataServerUrl || this.http.getBaseUrl();
4655
4800
  const wsUrl = baseUrl.replace(/^http/, "ws");
4656
- let url = `${wsUrl}/v1/database/realtime/ws?access_token=${encodeURIComponent(this.realtimeOptions.accessToken)}`;
4801
+ let url = ticket ? `${wsUrl}/v1/database/realtime/ws?ticket=${encodeURIComponent(ticket)}` : `${wsUrl}/v1/database/realtime/ws?access_token=${encodeURIComponent(this.realtimeOptions.accessToken)}`;
4657
4802
  this.realtimeTokenRefresh = this.http.canRefreshMemberToken(
4658
4803
  this.realtimeOptions.accessToken
4659
4804
  );
4660
4805
  if (this.realtimeTokenRefresh) url += "&token_refresh=1";
4806
+ url += "&reject_mode=close";
4661
4807
  return new Promise((resolve, reject) => {
4662
4808
  try {
4663
- this.realtimeWs = new WebSocket(url);
4809
+ const ws = new WebSocket(url);
4810
+ this.realtimeWs = ws;
4664
4811
  let settled = false;
4812
+ let accepted = false;
4813
+ let rejection = null;
4665
4814
  const connectTimeout = setTimeout(() => {
4666
4815
  if (!settled) {
4667
4816
  settled = true;
@@ -4672,8 +4821,9 @@ var DatabaseAPI = class {
4672
4821
  this.setRealtimeState("disconnected");
4673
4822
  reject(new Error("Connection timeout"));
4674
4823
  }
4675
- }, 15e3);
4676
- this.realtimeWs.onopen = () => {
4824
+ }, REALTIME_CONNECT_TIMEOUT_MS);
4825
+ const accept = () => {
4826
+ accepted = true;
4677
4827
  if (!settled) {
4678
4828
  settled = true;
4679
4829
  clearTimeout(connectTimeout);
@@ -4686,15 +4836,28 @@ var DatabaseAPI = class {
4686
4836
  this.resubscribeAll();
4687
4837
  resolve();
4688
4838
  };
4689
- this.realtimeWs.onmessage = (event) => {
4839
+ ws.onopen = () => {
4840
+ ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
4841
+ };
4842
+ ws.onmessage = (event) => {
4843
+ let msg;
4690
4844
  try {
4691
- const msg = JSON.parse(event.data);
4692
- this.handleRealtimeMessage(msg);
4845
+ msg = JSON.parse(event.data);
4693
4846
  } catch {
4694
4847
  this.debugLog("Failed to parse realtime message");
4848
+ return;
4695
4849
  }
4850
+ if (!accepted) {
4851
+ const rejected = toRealtimeRejection(msg);
4852
+ if (rejected) {
4853
+ rejection = rejected;
4854
+ return;
4855
+ }
4856
+ accept();
4857
+ }
4858
+ this.handleRealtimeMessage(msg);
4696
4859
  };
4697
- this.realtimeWs.onclose = (event) => {
4860
+ ws.onclose = (event) => {
4698
4861
  this.debugLog(
4699
4862
  `Database realtime disconnected (code=${event?.code}, reason=${event?.reason})`
4700
4863
  );
@@ -4703,12 +4866,25 @@ var DatabaseAPI = class {
4703
4866
  this.clearRealtimeAuthRetry();
4704
4867
  const notice = this.realtimeCloseNotice;
4705
4868
  this.realtimeCloseNotice = null;
4869
+ const code = event?.code;
4870
+ if (!accepted && code >= 4e3 && code <= 4999) {
4871
+ const err = rejection ?? new RealtimeConnectionError({
4872
+ code: event.reason || void 0,
4873
+ closeCode: code
4874
+ });
4875
+ if (!settled) {
4876
+ settled = true;
4877
+ clearTimeout(connectTimeout);
4878
+ reject(err);
4879
+ }
4880
+ this.handleRealtimeRejection(err);
4881
+ return;
4882
+ }
4706
4883
  if (!settled) {
4707
4884
  settled = true;
4708
4885
  clearTimeout(connectTimeout);
4709
4886
  reject(new Error("Connection closed during handshake"));
4710
4887
  }
4711
- const code = event?.code;
4712
4888
  if (this.realtimeOptions && this.realtimeState !== "disconnected" && (code === CLOSE_CODE_MEMBER_REVOKED || code === CLOSE_CODE_TOKEN_EXPIRED && !this.http.canRefreshMemberToken(
4713
4889
  this.realtimeOptions.accessToken
4714
4890
  ))) {
@@ -4726,7 +4902,7 @@ var DatabaseAPI = class {
4726
4902
  this.attemptRealtimeReconnect();
4727
4903
  }
4728
4904
  };
4729
- this.realtimeWs.onerror = () => {
4905
+ ws.onerror = () => {
4730
4906
  this.debugLog("Database realtime error");
4731
4907
  this.realtimeOnError?.(new Error("WebSocket connection error"));
4732
4908
  };
@@ -4736,6 +4912,21 @@ var DatabaseAPI = class {
4736
4912
  }
4737
4913
  });
4738
4914
  }
4915
+ /**
4916
+ * 서버가 연결을 거부했다(티켓 발급 거부, 또는 reject_mode=close 로 받은 업그레이드 거부). cb.realtime 과 같은 규칙이다 —
4917
+ * 다시 해서 될 거부(`retryable`: 일시 장애, 속도 제한, 동시접속 한도, 만료된 티켓)는 서버가 알려 준 시각(`retryAfterMs`)
4918
+ * 이후로 재연결을 이어 가고, 아니면(자격증명, 멤버 폐기, 할당량) 멈추고 `onRealtimeError` 로 알린다. 재시도를 다 써도
4919
+ * 거부되면 마지막 거부를 알린다.
4920
+ */
4921
+ handleRealtimeRejection(err) {
4922
+ if (!this.realtimeOptions || this.realtimeState === "disconnected") return;
4923
+ if (err.retryable) {
4924
+ this.attemptRealtimeReconnect(err);
4925
+ return;
4926
+ }
4927
+ this.setRealtimeState("disconnected");
4928
+ this.realtimeOnError?.(err);
4929
+ }
4739
4930
  /**
4740
4931
  * 서버에 subscribe 메시지를 보내고 서버 subscription_id를 반환하는 Promise
4741
4932
  */
@@ -4908,21 +5099,25 @@ var DatabaseAPI = class {
4908
5099
  break;
4909
5100
  }
4910
5101
  }
4911
- attemptRealtimeReconnect() {
5102
+ /**
5103
+ * 지수 백오프로 다시 붙는다. cause 는 이번 시도를 끝낸 서버의 거부다 — 알려 준 재시도 시각(`retryAfterMs`)보다 일찍 다시
5104
+ * 하지 않고, 재시도를 다 쓰면 일반 오류 대신 그 거부를 알린다.
5105
+ */
5106
+ attemptRealtimeReconnect(cause) {
4912
5107
  const maxRetries = this.realtimeOptions?.maxRetries ?? 5;
4913
5108
  const retryInterval = this.realtimeOptions?.retryInterval ?? 1e3;
4914
5109
  if (this.realtimeRetryCount >= maxRetries) {
4915
5110
  this.setRealtimeState("disconnected");
4916
5111
  this.realtimeOnError?.(
4917
- new Error("Realtime connection lost. Max retries exceeded.")
5112
+ cause ?? new Error("Realtime connection lost. Max retries exceeded.")
4918
5113
  );
4919
5114
  return;
4920
5115
  }
4921
5116
  this.setRealtimeState("connecting");
4922
5117
  this.realtimeRetryCount++;
4923
- const delay = Math.min(
4924
- retryInterval * 2 ** (this.realtimeRetryCount - 1),
4925
- 3e4
5118
+ const delay = Math.max(
5119
+ Math.min(retryInterval * 2 ** (this.realtimeRetryCount - 1), 3e4),
5120
+ cause?.retryAfterMs ?? 0
4926
5121
  );
4927
5122
  this.debugLog(
4928
5123
  `Reconnecting in ${delay}ms (attempt ${this.realtimeRetryCount}/${maxRetries})`
@@ -5213,7 +5408,7 @@ var EndpointAPI = class {
5213
5408
  );
5214
5409
  }
5215
5410
  const baseUrl = this.http.getBaseUrl().replace(/\/+$/, "");
5216
- const ticketUrl = `${baseUrl}/v1/proxy/${encodeURIComponent(label)}/ws-ticket`;
5411
+ const ticketUrl2 = `${baseUrl}/v1/proxy/${encodeURIComponent(label)}/ws-ticket`;
5217
5412
  const ticketHeaders = new Headers();
5218
5413
  const pk = this.http.getPublicKey();
5219
5414
  if (!pk) {
@@ -5222,7 +5417,7 @@ var EndpointAPI = class {
5222
5417
  );
5223
5418
  }
5224
5419
  ticketHeaders.set("X-Public-Key", pk);
5225
- const ticketRes = await fetch(ticketUrl, {
5420
+ const ticketRes = await fetch(ticketUrl2, {
5226
5421
  method: "POST",
5227
5422
  headers: ticketHeaders,
5228
5423
  signal: opts.signal
@@ -5920,20 +6115,54 @@ var GameRoom = class {
5920
6115
  * 게임 서버에 연결
5921
6116
  */
5922
6117
  connect(roomId) {
5923
- return new Promise((resolve, reject) => {
5924
- if (this.ws?.readyState === WebSocket.OPEN) {
5925
- resolve();
5926
- return;
5927
- }
5928
- if (!this.config.appId) {
5929
- reject(
5930
- new Error(
5931
- "cb.game: appId is required to connect. Pass it to `new ConnectBase({ appId })` or per-client via `cb.game.createClient({ appId, clientId })`."
5932
- )
5933
- );
5934
- return;
6118
+ if (this.ws?.readyState === WebSocket.OPEN) {
6119
+ return Promise.resolve();
6120
+ }
6121
+ if (!this.config.appId) {
6122
+ return Promise.reject(
6123
+ new Error(
6124
+ "cb.game: appId is required to connect. Pass it to `new ConnectBase({ appId })` or per-client via `cb.game.createClient({ appId, clientId })`."
6125
+ )
6126
+ );
6127
+ }
6128
+ if (!this.config.clientId) {
6129
+ return Promise.reject(
6130
+ new Error(
6131
+ "cb.game: clientId is required to build a game connection URL. Pass it via `cb.game.createClient({ appId, clientId })`."
6132
+ )
6133
+ );
6134
+ }
6135
+ return this.connectionTicket().then(
6136
+ (ticket) => this.openSocket(roomId, ticket)
6137
+ );
6138
+ }
6139
+ /**
6140
+ * 연결 시도 한 번에 쓸 1회용 티켓을 받는다(core/ws-ticket.ts). 브라우저 WebSocket 은 헤더를 못 붙여 예전에는 멤버
6141
+ * 토큰(`?token=`)과 공개 키(`?public_key=`)를 URL 에 실었다 — 이제 헤더로 티켓을 받아 `?ticket=` 만 싣는다. 서버가
6142
+ * 티켓을 모르거나(구버전) 잠시 못 주면 null 이고 예전처럼 붙는다. 발급이 자격증명을 거부하면 {@link GameError} 로 던진다.
6143
+ */
6144
+ async connectionTicket() {
6145
+ const { accessToken, publicKey, appId, gameServerUrl } = this.config;
6146
+ if (!accessToken && !publicKey || !appId || !gameServerUrl) return null;
6147
+ try {
6148
+ return await fetchWsTicket(
6149
+ ticketUrl(
6150
+ gameServerUrl,
6151
+ `/v1/game/${encodeURIComponent(appId)}/ws-ticket`
6152
+ ),
6153
+ "game.ws",
6154
+ accessToken ? { kind: "access_token", value: accessToken } : { kind: "public_key", value: publicKey }
6155
+ );
6156
+ } catch (e) {
6157
+ if (e instanceof WsTicketError) {
6158
+ throw new GameError({ code: e.code, message: e.message });
5935
6159
  }
5936
- const url = this.buildConnectionUrl(roomId);
6160
+ throw e;
6161
+ }
6162
+ }
6163
+ openSocket(roomId, ticket) {
6164
+ return new Promise((resolve, reject) => {
6165
+ const url = this.buildConnectionUrl(roomId, ticket);
5937
6166
  this.ws = new WebSocket(url);
5938
6167
  const onOpen = () => {
5939
6168
  this._isConnected = true;
@@ -6189,7 +6418,7 @@ var GameRoom = class {
6189
6418
  });
6190
6419
  }
6191
6420
  // Private methods
6192
- buildConnectionUrl(roomId) {
6421
+ buildConnectionUrl(roomId, ticket) {
6193
6422
  const baseUrl = this.config.gameServerUrl;
6194
6423
  const wsUrl = baseUrl.replace(/^http/, "ws");
6195
6424
  const params = new URLSearchParams();
@@ -6202,11 +6431,15 @@ var GameRoom = class {
6202
6431
  if (roomId) {
6203
6432
  params.set("room_id", roomId);
6204
6433
  }
6205
- if (this.config.publicKey) {
6206
- params.set("public_key", this.config.publicKey);
6207
- }
6208
- if (this.config.accessToken) {
6209
- params.set("token", this.config.accessToken);
6434
+ if (ticket) {
6435
+ params.set("ticket", ticket);
6436
+ } else {
6437
+ if (this.config.publicKey) {
6438
+ params.set("public_key", this.config.publicKey);
6439
+ }
6440
+ if (this.config.accessToken) {
6441
+ params.set("token", this.config.accessToken);
6442
+ }
6210
6443
  }
6211
6444
  if (!this.config.appId) {
6212
6445
  throw new Error(
@@ -10063,7 +10296,14 @@ var ConnectCancelledError = class extends Error {
10063
10296
  }
10064
10297
  };
10065
10298
  var MAX_HANDSHAKE_RETRIES = 2;
10066
- var RETRY_IN_CONNECT_CODES = /* @__PURE__ */ new Set(["rate_limited", "auth_unavailable"]);
10299
+ var RETRY_IN_CONNECT_CODES = /* @__PURE__ */ new Set([
10300
+ "rate_limited",
10301
+ "auth_unavailable",
10302
+ // 1회용 티켓이 업그레이드에서 거부됐다(만료, 이미 쓰임, 저장소 일시 장애). 다음 시도는 새 티켓을 받거나, 저장소가
10303
+ // 없으면 예전 방식으로 붙는다(core/ws-ticket.ts).
10304
+ "invalid_ws_ticket",
10305
+ "ws_ticket_unavailable"
10306
+ ]);
10067
10307
  var CONNECTION_TOKEN_MIN_VALIDITY_SEC = 600;
10068
10308
  var CONNECT_TOKEN_MIN_VALIDITY_SEC = 60;
10069
10309
  var AUTH_REFRESH_RETRY_MS = 3e4;
@@ -11178,7 +11418,10 @@ var RealtimeAPI = class {
11178
11418
  for (let attempt = 1; ; attempt++) {
11179
11419
  try {
11180
11420
  await this.prepareConnectToken();
11181
- await this.doConnect(Math.max(deadline - Date.now(), 1));
11421
+ const ticket = await this.connectTicket(
11422
+ Math.max(deadline - Date.now(), 1)
11423
+ );
11424
+ await this.doConnect(Math.max(deadline - Date.now(), 1), ticket);
11182
11425
  return;
11183
11426
  } catch (e) {
11184
11427
  let wait = null;
@@ -11221,7 +11464,39 @@ var RealtimeAPI = class {
11221
11464
  this.initialConnecting = false;
11222
11465
  }
11223
11466
  }
11224
- async doConnect(timeoutMs = this.options.timeout) {
11467
+ /**
11468
+ * 연결 시도 한 번에 쓸 1회용 티켓을 받는다(core/ws-ticket.ts). 브라우저 WebSocket 은 헤더를 못 붙여 예전에는 멤버
11469
+ * 토큰과 공개 키를 URL 에 실었다 — 이제 헤더로 티켓을 받아 `?ticket=` 만 싣는다. 티켓은 한 번만 쓰이므로 시도마다
11470
+ * 새로 받는다.
11471
+ *
11472
+ * 서버가 티켓을 모르거나(구버전) 잠시 못 주면 null 이고, doConnect 가 예전처럼 자격증명을 URL 에 싣는다. 발급이
11473
+ * 자격증명을 거부하면 업그레이드 거부와 같은 {@link RealtimeConnectionError} 로 던진다 — 재시도 규칙이 같다.
11474
+ */
11475
+ async connectTicket(timeoutMs) {
11476
+ const accessToken = this.options.accessToken;
11477
+ const publicKey = accessToken ? void 0 : this.http.getPublicKey();
11478
+ if (!accessToken && !publicKey) return null;
11479
+ try {
11480
+ return await fetchWsTicket(
11481
+ ticketUrl(this.socketUrl, "/v1/realtime/ws-ticket"),
11482
+ "socket.realtime",
11483
+ accessToken ? { kind: "access_token", value: accessToken } : { kind: "public_key", value: publicKey },
11484
+ { timeoutMs }
11485
+ );
11486
+ } catch (e) {
11487
+ if (e instanceof WsTicketError) {
11488
+ this.log(`WebSocket ticket rejected: ${e.code} (${e.status})`);
11489
+ throw new RealtimeConnectionError({
11490
+ code: e.code,
11491
+ message: e.message,
11492
+ closeCode: 4e3 + e.status,
11493
+ retryAfterMs: e.retryAfterMs
11494
+ });
11495
+ }
11496
+ throw e;
11497
+ }
11498
+ }
11499
+ async doConnect(timeoutMs = this.options.timeout, ticket = null) {
11225
11500
  return new Promise((resolve, reject) => {
11226
11501
  this.state = "connecting";
11227
11502
  this.notifyStateChange();
@@ -11230,9 +11505,10 @@ var RealtimeAPI = class {
11230
11505
  const usingDirect = !!directWsBase;
11231
11506
  const wsUrl = (directWsBase ?? this.socketUrl).replace(/^http/, "ws");
11232
11507
  let url;
11508
+ const auth = ticket ? `ticket=${encodeURIComponent(ticket)}` : void 0;
11233
11509
  this.tokenRefreshNegotiated = false;
11234
11510
  if (this.options.accessToken) {
11235
- url = `${wsUrl}/v1/realtime/auth?access_token=${encodeURIComponent(this.options.accessToken)}&client_id=${this.clientId}`;
11511
+ url = `${wsUrl}/v1/realtime/auth?${auth ?? `access_token=${encodeURIComponent(this.options.accessToken)}`}&client_id=${this.clientId}`;
11236
11512
  if (this.http.canRefreshMemberToken(this.options.accessToken)) {
11237
11513
  this.tokenRefreshNegotiated = true;
11238
11514
  url += "&token_refresh=1";
@@ -11248,7 +11524,7 @@ var RealtimeAPI = class {
11248
11524
  reject(error);
11249
11525
  return;
11250
11526
  }
11251
- url = `${wsUrl}/v1/realtime/auth?public_key=${encodeURIComponent(publicKey)}&client_id=${this.clientId}`;
11527
+ url = `${wsUrl}/v1/realtime/auth?${auth ?? `public_key=${encodeURIComponent(publicKey)}`}&client_id=${this.clientId}`;
11252
11528
  this.log("Using API Key authentication");
11253
11529
  }
11254
11530
  if (this.userId) {
@@ -11728,7 +12004,9 @@ var RealtimeAPI = class {
11728
12004
  try {
11729
12005
  await this.prepareConnectToken();
11730
12006
  if (this.state !== "reconnecting") return;
11731
- await this.doConnect();
12007
+ const ticket = await this.connectTicket(this.options.timeout);
12008
+ if (this.state !== "reconnecting") return;
12009
+ await this.doConnect(void 0, ticket);
11732
12010
  this.log("Reconnected successfully, restoring subscriptions...");
11733
12011
  const snapshot = this.pendingRestore;
11734
12012
  this.pendingRestore = null;
@@ -13331,8 +13609,8 @@ var SupportAPI = class {
13331
13609
  }
13332
13610
  };
13333
13611
  function detectSdkVersion() {
13334
- if ("6.13.0") {
13335
- return "6.13.0";
13612
+ if ("6.15.0") {
13613
+ return "6.15.0";
13336
13614
  }
13337
13615
  return "unknown";
13338
13616
  }
@@ -14301,9 +14579,35 @@ var WebRTCAPI = class _WebRTCAPI {
14301
14579
  }
14302
14580
  return this.connectWebSocket();
14303
14581
  }
14304
- connectWebSocket() {
14582
+ async connectWebSocket() {
14583
+ const before = this.wsGeneration;
14584
+ const ticket = await this.signalingTicket();
14585
+ if (this.wsGeneration !== before) {
14586
+ throw new Error("\uC5F0\uACB0\uC774 \uCDE8\uC18C\uB418\uC5C8\uC2B5\uB2C8\uB2E4");
14587
+ }
14588
+ return this.openSignalingSocket(ticket);
14589
+ }
14590
+ /**
14591
+ * 연결 시도 한 번에 쓸 1회용 티켓을 받는다(core/ws-ticket.ts). 브라우저 WebSocket 은 헤더를 못 붙여 예전에는 멤버 토큰
14592
+ * (`?access_token=`)과 공개 키(`?public_key=`)를 URL 에 실었다 — 이제 헤더로 티켓을 받아 `?ticket=` 만 싣는다. 서버가
14593
+ * 티켓을 모르거나(구버전) 잠시 못 주면 null 이고 예전처럼 붙는다. 발급이 자격증명을 거부하면 그 이유로 던진다.
14594
+ */
14595
+ async signalingTicket() {
14596
+ const accessToken = this.http.getAccessToken();
14597
+ const publicKey = accessToken ? void 0 : this.http.getPublicKey();
14598
+ if (!accessToken && !publicKey || !this.appId) return null;
14599
+ return fetchWsTicket(
14600
+ ticketUrl(
14601
+ this.webrtcUrl,
14602
+ `/v1/apps/${encodeURIComponent(this.appId)}/signaling/ws-ticket`
14603
+ ),
14604
+ "webrtc.signaling",
14605
+ accessToken ? { kind: "access_token", value: accessToken } : { kind: "public_key", value: publicKey }
14606
+ );
14607
+ }
14608
+ openSignalingSocket(ticket) {
14305
14609
  return new Promise((resolve, reject) => {
14306
- const wsUrl = this.buildWebSocketUrl();
14610
+ const wsUrl = this.buildWebSocketUrl(ticket);
14307
14611
  const generation = ++this.wsGeneration;
14308
14612
  const ws = new WebSocket(wsUrl);
14309
14613
  this.ws = ws;
@@ -14372,12 +14676,14 @@ var WebRTCAPI = class _WebRTCAPI {
14372
14676
  ws.onerror = null;
14373
14677
  ws.onclose = null;
14374
14678
  }
14375
- buildWebSocketUrl() {
14679
+ buildWebSocketUrl(ticket = null) {
14376
14680
  const wsBase = this.webrtcUrl.replace("https://", "wss://").replace("http://", "ws://");
14377
14681
  const publicKey = this.http.getPublicKey();
14378
14682
  const accessToken = this.http.getAccessToken();
14379
14683
  let authParam = "";
14380
- if (accessToken) {
14684
+ if (ticket) {
14685
+ authParam = `ticket=${encodeURIComponent(ticket)}`;
14686
+ } else if (accessToken) {
14381
14687
  authParam = `access_token=${encodeURIComponent(accessToken)}`;
14382
14688
  } else if (publicKey) {
14383
14689
  authParam = `public_key=${encodeURIComponent(publicKey)}`;
@@ -15035,11 +15341,35 @@ var WebSocketTransport = class {
15035
15341
  this.onClose = onClose;
15036
15342
  this.onError = onError;
15037
15343
  }
15038
- connect() {
15344
+ async connect() {
15345
+ const ticket = await this.connectionTicket();
15346
+ return this.openSocket(ticket);
15347
+ }
15348
+ /**
15349
+ * 연결 시도 한 번에 쓸 1회용 티켓(core/ws-ticket.ts). 서버가 티켓을 모르거나(구버전) 잠시 못 주면 null 이고 예전처럼
15350
+ * 자격증명을 URL 에 싣는다. 발급이 자격증명을 거부하면 {@link GameError} 로 던진다.
15351
+ */
15352
+ async connectionTicket() {
15353
+ const { accessToken, publicKey, appId } = this.config;
15354
+ if (!accessToken && !publicKey || !appId) return null;
15355
+ const baseUrl = this.config.gameServerUrl || "wss://game.connectbase.world";
15356
+ try {
15357
+ return await fetchWsTicket(
15358
+ ticketUrl(baseUrl, `/v1/game/${encodeURIComponent(appId)}/ws-ticket`),
15359
+ "game.ws",
15360
+ accessToken ? { kind: "access_token", value: accessToken } : { kind: "public_key", value: publicKey }
15361
+ );
15362
+ } catch (e) {
15363
+ if (e instanceof WsTicketError) {
15364
+ throw new GameError({ code: e.code, message: e.message });
15365
+ }
15366
+ throw e;
15367
+ }
15368
+ }
15369
+ openSocket(ticket) {
15039
15370
  return new Promise((resolve, reject) => {
15040
- const url = this.buildUrl();
15041
15371
  try {
15042
- this.ws = new WebSocket(url);
15372
+ this.ws = new WebSocket(this.buildUrl(ticket));
15043
15373
  this.ws.binaryType = "arraybuffer";
15044
15374
  } catch (error) {
15045
15375
  reject(error);
@@ -15069,16 +15399,20 @@ var WebSocketTransport = class {
15069
15399
  this.ws.addEventListener("message", onMessage);
15070
15400
  });
15071
15401
  }
15072
- buildUrl() {
15402
+ buildUrl(ticket = null) {
15073
15403
  const baseUrl = this.config.gameServerUrl || "wss://game.connectbase.world";
15074
15404
  const wsUrl = baseUrl.replace(/^http/, "ws");
15075
15405
  const params = new URLSearchParams();
15076
15406
  params.set("client_id", requireClientId(this.config.clientId));
15077
- if (this.config.publicKey) {
15078
- params.set("public_key", this.config.publicKey);
15079
- }
15080
- if (this.config.accessToken) {
15081
- params.set("token", this.config.accessToken);
15407
+ if (ticket) {
15408
+ params.set("ticket", ticket);
15409
+ } else {
15410
+ if (this.config.publicKey) {
15411
+ params.set("public_key", this.config.publicKey);
15412
+ }
15413
+ if (this.config.accessToken) {
15414
+ params.set("token", this.config.accessToken);
15415
+ }
15082
15416
  }
15083
15417
  if (!this.config.appId) {
15084
15418
  throw new Error(
@@ -15758,6 +16092,7 @@ var index_default = ConnectBase;
15758
16092
  RolesAPI,
15759
16093
  SessionManager,
15760
16094
  VideoProcessingError,
16095
+ WsTicketError,
15761
16096
  detectInAppBrowser,
15762
16097
  escapeToExternalBrowser,
15763
16098
  isWebTransportSupported,