connectbase-client 6.12.0 → 6.14.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;
@@ -256,7 +256,7 @@ var RealtimeConnectionError = class extends Error {
256
256
  }
257
257
  };
258
258
  function isRetryableRealtimeRejection(code) {
259
- return code === "connection_limit_exceeded" || code === "server_at_capacity" || code === "rate_limited" || code === "auth_unavailable" || code === "internal_error";
259
+ return code === "connection_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",
@@ -4593,15 +4691,52 @@ var DatabaseAPI = class {
4593
4691
  );
4594
4692
  if (fresh) opts.accessToken = fresh;
4595
4693
  }
4596
- return this.doRealtimeConnect();
4694
+ let ticket;
4695
+ try {
4696
+ ticket = await this.realtimeTicket();
4697
+ } catch (e) {
4698
+ if (e instanceof WsTicketError) {
4699
+ if (e.status === 429 && this.realtimeOptions) {
4700
+ this.attemptRealtimeReconnect();
4701
+ } else {
4702
+ this.setRealtimeState("disconnected");
4703
+ }
4704
+ const err = new RealtimeConnectionError({
4705
+ code: e.code,
4706
+ message: e.message,
4707
+ closeCode: 4e3 + e.status,
4708
+ retryAfterMs: e.retryAfterMs
4709
+ });
4710
+ this.realtimeOnError?.(err);
4711
+ throw err;
4712
+ }
4713
+ throw e;
4714
+ }
4715
+ return this.doRealtimeConnect(ticket);
4597
4716
  }
4598
- doRealtimeConnect() {
4717
+ /**
4718
+ * 연결 시도 한 번에 쓸 1회용 티켓을 받는다(core/ws-ticket.ts). 브라우저 WebSocket 은 헤더를 못 붙여 예전에는 멤버 토큰을
4719
+ * URL(`?access_token=`)에 실었다 — 이제 헤더로 티켓을 받아 `?ticket=` 만 싣는다. 서버가 티켓을 모르거나(구버전) 잠시 못
4720
+ * 주면 null 이고 예전처럼 붙는다. cross-app OAuth 토큰도 같은 경로다(서버가 티켓에서 토큰을 되살려 같은 검증을 한다).
4721
+ */
4722
+ async realtimeTicket() {
4723
+ const opts = this.realtimeOptions;
4724
+ if (!opts?.accessToken) return null;
4725
+ const baseUrl = opts.dataServerUrl || this.http.getBaseUrl();
4726
+ return fetchWsTicket(
4727
+ ticketUrl(baseUrl, "/v1/database/realtime/ws-ticket"),
4728
+ "data.realtime",
4729
+ { kind: "access_token", value: opts.accessToken },
4730
+ { timeoutMs: REALTIME_CONNECT_TIMEOUT_MS }
4731
+ );
4732
+ }
4733
+ doRealtimeConnect(ticket = null) {
4599
4734
  if (!this.realtimeOptions)
4600
4735
  return Promise.reject(new Error("No realtime options"));
4601
4736
  this.setRealtimeState("connecting");
4602
4737
  const baseUrl = this.realtimeOptions.dataServerUrl || this.http.getBaseUrl();
4603
4738
  const wsUrl = baseUrl.replace(/^http/, "ws");
4604
- let url = `${wsUrl}/v1/database/realtime/ws?access_token=${encodeURIComponent(this.realtimeOptions.accessToken)}`;
4739
+ let url = ticket ? `${wsUrl}/v1/database/realtime/ws?ticket=${encodeURIComponent(ticket)}` : `${wsUrl}/v1/database/realtime/ws?access_token=${encodeURIComponent(this.realtimeOptions.accessToken)}`;
4605
4740
  this.realtimeTokenRefresh = this.http.canRefreshMemberToken(
4606
4741
  this.realtimeOptions.accessToken
4607
4742
  );
@@ -4620,7 +4755,7 @@ var DatabaseAPI = class {
4620
4755
  this.setRealtimeState("disconnected");
4621
4756
  reject(new Error("Connection timeout"));
4622
4757
  }
4623
- }, 15e3);
4758
+ }, REALTIME_CONNECT_TIMEOUT_MS);
4624
4759
  this.realtimeWs.onopen = () => {
4625
4760
  if (!settled) {
4626
4761
  settled = true;
@@ -5161,7 +5296,7 @@ var EndpointAPI = class {
5161
5296
  );
5162
5297
  }
5163
5298
  const baseUrl = this.http.getBaseUrl().replace(/\/+$/, "");
5164
- const ticketUrl = `${baseUrl}/v1/proxy/${encodeURIComponent(label)}/ws-ticket`;
5299
+ const ticketUrl2 = `${baseUrl}/v1/proxy/${encodeURIComponent(label)}/ws-ticket`;
5165
5300
  const ticketHeaders = new Headers();
5166
5301
  const pk = this.http.getPublicKey();
5167
5302
  if (!pk) {
@@ -5170,7 +5305,7 @@ var EndpointAPI = class {
5170
5305
  );
5171
5306
  }
5172
5307
  ticketHeaders.set("X-Public-Key", pk);
5173
- const ticketRes = await fetch(ticketUrl, {
5308
+ const ticketRes = await fetch(ticketUrl2, {
5174
5309
  method: "POST",
5175
5310
  headers: ticketHeaders,
5176
5311
  signal: opts.signal
@@ -5868,20 +6003,54 @@ var GameRoom = class {
5868
6003
  * 게임 서버에 연결
5869
6004
  */
5870
6005
  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;
6006
+ if (this.ws?.readyState === WebSocket.OPEN) {
6007
+ return Promise.resolve();
6008
+ }
6009
+ if (!this.config.appId) {
6010
+ return Promise.reject(
6011
+ new Error(
6012
+ "cb.game: appId is required to connect. Pass it to `new ConnectBase({ appId })` or per-client via `cb.game.createClient({ appId, clientId })`."
6013
+ )
6014
+ );
6015
+ }
6016
+ if (!this.config.clientId) {
6017
+ return Promise.reject(
6018
+ new Error(
6019
+ "cb.game: clientId is required to build a game connection URL. Pass it via `cb.game.createClient({ appId, clientId })`."
6020
+ )
6021
+ );
6022
+ }
6023
+ return this.connectionTicket().then(
6024
+ (ticket) => this.openSocket(roomId, ticket)
6025
+ );
6026
+ }
6027
+ /**
6028
+ * 연결 시도 한 번에 쓸 1회용 티켓을 받는다(core/ws-ticket.ts). 브라우저 WebSocket 은 헤더를 못 붙여 예전에는 멤버
6029
+ * 토큰(`?token=`)과 공개 키(`?public_key=`)를 URL 에 실었다 — 이제 헤더로 티켓을 받아 `?ticket=` 만 싣는다. 서버가
6030
+ * 티켓을 모르거나(구버전) 잠시 못 주면 null 이고 예전처럼 붙는다. 발급이 자격증명을 거부하면 {@link GameError} 로 던진다.
6031
+ */
6032
+ async connectionTicket() {
6033
+ const { accessToken, publicKey, appId, gameServerUrl } = this.config;
6034
+ if (!accessToken && !publicKey || !appId || !gameServerUrl) return null;
6035
+ try {
6036
+ return await fetchWsTicket(
6037
+ ticketUrl(
6038
+ gameServerUrl,
6039
+ `/v1/game/${encodeURIComponent(appId)}/ws-ticket`
6040
+ ),
6041
+ "game.ws",
6042
+ accessToken ? { kind: "access_token", value: accessToken } : { kind: "public_key", value: publicKey }
6043
+ );
6044
+ } catch (e) {
6045
+ if (e instanceof WsTicketError) {
6046
+ throw new GameError({ code: e.code, message: e.message });
5883
6047
  }
5884
- const url = this.buildConnectionUrl(roomId);
6048
+ throw e;
6049
+ }
6050
+ }
6051
+ openSocket(roomId, ticket) {
6052
+ return new Promise((resolve, reject) => {
6053
+ const url = this.buildConnectionUrl(roomId, ticket);
5885
6054
  this.ws = new WebSocket(url);
5886
6055
  const onOpen = () => {
5887
6056
  this._isConnected = true;
@@ -6137,7 +6306,7 @@ var GameRoom = class {
6137
6306
  });
6138
6307
  }
6139
6308
  // Private methods
6140
- buildConnectionUrl(roomId) {
6309
+ buildConnectionUrl(roomId, ticket) {
6141
6310
  const baseUrl = this.config.gameServerUrl;
6142
6311
  const wsUrl = baseUrl.replace(/^http/, "ws");
6143
6312
  const params = new URLSearchParams();
@@ -6150,11 +6319,15 @@ var GameRoom = class {
6150
6319
  if (roomId) {
6151
6320
  params.set("room_id", roomId);
6152
6321
  }
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);
6322
+ if (ticket) {
6323
+ params.set("ticket", ticket);
6324
+ } else {
6325
+ if (this.config.publicKey) {
6326
+ params.set("public_key", this.config.publicKey);
6327
+ }
6328
+ if (this.config.accessToken) {
6329
+ params.set("token", this.config.accessToken);
6330
+ }
6158
6331
  }
6159
6332
  if (!this.config.appId) {
6160
6333
  throw new Error(
@@ -10011,7 +10184,14 @@ var ConnectCancelledError = class extends Error {
10011
10184
  }
10012
10185
  };
10013
10186
  var MAX_HANDSHAKE_RETRIES = 2;
10014
- var RETRY_IN_CONNECT_CODES = /* @__PURE__ */ new Set(["rate_limited", "auth_unavailable"]);
10187
+ var RETRY_IN_CONNECT_CODES = /* @__PURE__ */ new Set([
10188
+ "rate_limited",
10189
+ "auth_unavailable",
10190
+ // 1회용 티켓이 업그레이드에서 거부됐다(만료, 이미 쓰임, 저장소 일시 장애). 다음 시도는 새 티켓을 받거나, 저장소가
10191
+ // 없으면 예전 방식으로 붙는다(core/ws-ticket.ts).
10192
+ "invalid_ws_ticket",
10193
+ "ws_ticket_unavailable"
10194
+ ]);
10015
10195
  var CONNECTION_TOKEN_MIN_VALIDITY_SEC = 600;
10016
10196
  var CONNECT_TOKEN_MIN_VALIDITY_SEC = 60;
10017
10197
  var AUTH_REFRESH_RETRY_MS = 3e4;
@@ -11126,7 +11306,10 @@ var RealtimeAPI = class {
11126
11306
  for (let attempt = 1; ; attempt++) {
11127
11307
  try {
11128
11308
  await this.prepareConnectToken();
11129
- await this.doConnect(Math.max(deadline - Date.now(), 1));
11309
+ const ticket = await this.connectTicket(
11310
+ Math.max(deadline - Date.now(), 1)
11311
+ );
11312
+ await this.doConnect(Math.max(deadline - Date.now(), 1), ticket);
11130
11313
  return;
11131
11314
  } catch (e) {
11132
11315
  let wait = null;
@@ -11169,7 +11352,39 @@ var RealtimeAPI = class {
11169
11352
  this.initialConnecting = false;
11170
11353
  }
11171
11354
  }
11172
- async doConnect(timeoutMs = this.options.timeout) {
11355
+ /**
11356
+ * 연결 시도 한 번에 쓸 1회용 티켓을 받는다(core/ws-ticket.ts). 브라우저 WebSocket 은 헤더를 못 붙여 예전에는 멤버
11357
+ * 토큰과 공개 키를 URL 에 실었다 — 이제 헤더로 티켓을 받아 `?ticket=` 만 싣는다. 티켓은 한 번만 쓰이므로 시도마다
11358
+ * 새로 받는다.
11359
+ *
11360
+ * 서버가 티켓을 모르거나(구버전) 잠시 못 주면 null 이고, doConnect 가 예전처럼 자격증명을 URL 에 싣는다. 발급이
11361
+ * 자격증명을 거부하면 업그레이드 거부와 같은 {@link RealtimeConnectionError} 로 던진다 — 재시도 규칙이 같다.
11362
+ */
11363
+ async connectTicket(timeoutMs) {
11364
+ const accessToken = this.options.accessToken;
11365
+ const publicKey = accessToken ? void 0 : this.http.getPublicKey();
11366
+ if (!accessToken && !publicKey) return null;
11367
+ try {
11368
+ return await fetchWsTicket(
11369
+ ticketUrl(this.socketUrl, "/v1/realtime/ws-ticket"),
11370
+ "socket.realtime",
11371
+ accessToken ? { kind: "access_token", value: accessToken } : { kind: "public_key", value: publicKey },
11372
+ { timeoutMs }
11373
+ );
11374
+ } catch (e) {
11375
+ if (e instanceof WsTicketError) {
11376
+ this.log(`WebSocket ticket rejected: ${e.code} (${e.status})`);
11377
+ throw new RealtimeConnectionError({
11378
+ code: e.code,
11379
+ message: e.message,
11380
+ closeCode: 4e3 + e.status,
11381
+ retryAfterMs: e.retryAfterMs
11382
+ });
11383
+ }
11384
+ throw e;
11385
+ }
11386
+ }
11387
+ async doConnect(timeoutMs = this.options.timeout, ticket = null) {
11173
11388
  return new Promise((resolve, reject) => {
11174
11389
  this.state = "connecting";
11175
11390
  this.notifyStateChange();
@@ -11178,9 +11393,10 @@ var RealtimeAPI = class {
11178
11393
  const usingDirect = !!directWsBase;
11179
11394
  const wsUrl = (directWsBase ?? this.socketUrl).replace(/^http/, "ws");
11180
11395
  let url;
11396
+ const auth = ticket ? `ticket=${encodeURIComponent(ticket)}` : void 0;
11181
11397
  this.tokenRefreshNegotiated = false;
11182
11398
  if (this.options.accessToken) {
11183
- url = `${wsUrl}/v1/realtime/auth?access_token=${encodeURIComponent(this.options.accessToken)}&client_id=${this.clientId}`;
11399
+ url = `${wsUrl}/v1/realtime/auth?${auth ?? `access_token=${encodeURIComponent(this.options.accessToken)}`}&client_id=${this.clientId}`;
11184
11400
  if (this.http.canRefreshMemberToken(this.options.accessToken)) {
11185
11401
  this.tokenRefreshNegotiated = true;
11186
11402
  url += "&token_refresh=1";
@@ -11196,7 +11412,7 @@ var RealtimeAPI = class {
11196
11412
  reject(error);
11197
11413
  return;
11198
11414
  }
11199
- url = `${wsUrl}/v1/realtime/auth?public_key=${encodeURIComponent(publicKey)}&client_id=${this.clientId}`;
11415
+ url = `${wsUrl}/v1/realtime/auth?${auth ?? `public_key=${encodeURIComponent(publicKey)}`}&client_id=${this.clientId}`;
11200
11416
  this.log("Using API Key authentication");
11201
11417
  }
11202
11418
  if (this.userId) {
@@ -11676,7 +11892,9 @@ var RealtimeAPI = class {
11676
11892
  try {
11677
11893
  await this.prepareConnectToken();
11678
11894
  if (this.state !== "reconnecting") return;
11679
- await this.doConnect();
11895
+ const ticket = await this.connectTicket(this.options.timeout);
11896
+ if (this.state !== "reconnecting") return;
11897
+ await this.doConnect(void 0, ticket);
11680
11898
  this.log("Reconnected successfully, restoring subscriptions...");
11681
11899
  const snapshot = this.pendingRestore;
11682
11900
  this.pendingRestore = null;
@@ -12657,32 +12875,41 @@ var SubscriptionAPI = class {
12657
12875
  * 구독 생성
12658
12876
  * 정기결제 구독을 생성합니다.
12659
12877
  *
12660
- * @param data - 구독 정보
12661
- * @returns 생성된 구독 정보
12878
+ * **금액과 요금제는 서버가 정합니다.** 브라우저에서는 콘솔 가격표의 정기 상품 `product_id` 만 보내고, 서버가
12879
+ * 금액, 통화, 주기, 플랜명, 무료 체험, MoR PG 가격 ref 를 상품에서 정합니다. 금액을 직접 보내는 형태(레거시)는
12880
+ * 결제 설정의 금액 출처가 `client` 인 앱이거나 서버(시크릿 키, service_role 함수)에서 부를 때만 받습니다.
12881
+ *
12882
+ * @param data - 가격표 정기 상품(`product_id`) 또는 금액과 요금제, 빌링키, 고객 정보 등
12883
+ * @returns 생성된 구독 정보 (`product_id` 포함)
12662
12884
  *
12663
12885
  * @example
12664
12886
  * ```typescript
12887
+ * // 가격표 구독 (권장) — 금액, 주기, 체험 기간은 콘솔 가격표의 premium_month 가 정한다
12665
12888
  * const subscription = await client.subscription.create({
12666
12889
  * billing_key_id: 'bk_xxxxx',
12667
- * plan_name: '프리미엄 플랜',
12668
- * amount: 9900,
12669
- * billing_cycle: 'monthly',
12670
- * billing_day: 15, // 매월 15일 결제
12671
- * trial_days: 7 // 7일 무료 체험
12890
+ * product_id: 'premium_month',
12672
12891
  * })
12673
12892
  *
12674
- * // MoR(dodo/paddle) 구독 + 할인 코드
12893
+ * // MoR(dodo/paddle) 구독 + 할인 코드 — PG 가격 ref 도 상품의 provider_price_refs 에서 서버가 고른다
12675
12894
  * const promo = await client.subscription.create({
12895
+ * product_id: 'premium_month',
12896
+ * discount_code: 'LAUNCH50', // 첫 결제에 적용 (몇 주기까지인지는 PG 쿠폰 설정이 결정)
12897
+ * })
12898
+ *
12899
+ * // 금액 직접 지정 (레거시 앱, 서버 호출) — 가격표 전용 앱에서 브라우저가 보내면 403 client_amount_not_allowed
12900
+ * const legacy = await client.subscription.create({
12901
+ * billing_key_id: 'bk_xxxxx',
12676
12902
  * plan_name: '프리미엄 플랜',
12677
12903
  * amount: 9900,
12678
12904
  * billing_cycle: 'monthly',
12679
- * provider_price_ref: 'pdt_xxxxx', // PG 카탈로그의 recurring 상품
12680
- * discount_code: 'LAUNCH50', // 첫 결제에 적용 (몇 주기까지인지는 PG 쿠폰 설정이 결정)
12681
- * trial_days: 14, // Dodo 만 지원 — Paddle 은 400 trial_days_unsupported
12682
12905
  * })
12683
12906
  * ```
12684
12907
  *
12685
12908
  * @remarks
12909
+ * 권한은 구독의 `product_id` 로 지급합니다(`metadata` 는 클라이언트 값). 가격표 전용 앱의 브라우저 호출은
12910
+ * `start_now: false` 와 `billing_day` 를 쓸 수 없습니다(403 `client_schedule_not_allowed`) — 둘 다 한 번 낸 돈으로 쓰는
12911
+ * 기간을 늘립니다. 무료 체험은 상품의 `trial_days` 가 정합니다.
12912
+ *
12686
12913
  * 할인 코드(`discount_code`)와 `trial_days` 의 프로바이더 지원 범위가 다릅니다:
12687
12914
  * - **Dodo**: 할인 코드 O, 결제창 쿠폰 입력 토글 O, 구독별 체험 일수 O
12688
12915
  * - **Paddle**: 할인 코드 O(코드→내부 ID 자동 해소), 쿠폰 입력칸은 항상 노출(숨김 불가),
@@ -12754,9 +12981,9 @@ var SubscriptionAPI = class {
12754
12981
  *
12755
12982
  * @example
12756
12983
  * ```typescript
12984
+ * // 서버(시크릿 키, service_role 함수)에서 다른 가격표 상품으로 바꾸기 — 다음 청구부터 새 금액
12757
12985
  * await client.subscription.update(subscriptionId, {
12758
- * plan_name: '엔터프라이즈 플랜',
12759
- * amount: 29900
12986
+ * product_id: 'enterprise_month',
12760
12987
  * })
12761
12988
  *
12762
12989
  * // 다음 결제일을 특정 날짜로 옮기기 (기간권·보상)
@@ -12942,11 +13169,10 @@ var SubscriptionAPI = class {
12942
13169
  *
12943
13170
  * @example
12944
13171
  * ```typescript
13172
+ * // 가격표 정기 상품으로 바꾼다 — 새 PG 가격 ref 와 플랜명은 서버가 상품에서 정한다
12945
13173
  * await client.subscription.changePlan(subscriptionId, {
12946
- * provider_price_ref: 'pri_family_monthly', // Dodo 는 'pdt_*'
12947
- * plan_name: '가족 플랜',
12948
- * amount: 24900,
12949
- * proration_mode: 'prorated_immediately',
13174
+ * product_id: 'family_month',
13175
+ * proration_mode: 'prorated_immediately', // 브라우저는 지금 정산하는 모드만
12950
13176
  * })
12951
13177
  * ```
12952
13178
  */
@@ -12979,7 +13205,7 @@ var SubscriptionAPI = class {
12979
13205
  * @example
12980
13206
  * ```typescript
12981
13207
  * const preview = await client.subscription.previewChangePlan(subscriptionId, {
12982
- * provider_price_ref: 'pri_family_monthly',
13208
+ * product_id: 'family_month',
12983
13209
  * })
12984
13210
  * // 통화를 함께 포맷한다 — 금액만 쓰면 다통화에서 거짓 금액이 된다
12985
13211
  * format(preview.immediate_charge_amount, preview.currency)
@@ -13033,10 +13259,11 @@ var SubscriptionAPI = class {
13033
13259
  *
13034
13260
  * @example
13035
13261
  * ```typescript
13262
+ * // 가격표 단건 상품으로 청구 — 금액과 상품명은 서버가 정한다
13036
13263
  * const result = await client.subscription.chargeWithBillingKey({
13037
13264
  * billing_key_id: 'bk_xxxxx',
13038
- * amount: 15000,
13039
- * order_name: '추가 결제'
13265
+ * product_id: 'gem_100',
13266
+ * quantity: 2,
13040
13267
  * })
13041
13268
  *
13042
13269
  * if (result.status === 'done') {
@@ -13270,8 +13497,8 @@ var SupportAPI = class {
13270
13497
  }
13271
13498
  };
13272
13499
  function detectSdkVersion() {
13273
- if ("6.12.0") {
13274
- return "6.12.0";
13500
+ if ("6.14.0") {
13501
+ return "6.14.0";
13275
13502
  }
13276
13503
  return "unknown";
13277
13504
  }
@@ -14165,7 +14392,14 @@ var WebRTCAPI = class _WebRTCAPI {
14165
14392
  this.currentRoomId = null;
14166
14393
  this.currentPeerId = null;
14167
14394
  this.currentUserId = null;
14168
- this.isBroadcaster = false;
14395
+ /** connect 가 요청한 방송자 역할 (switchRoom 이 이어받는다) */
14396
+ this.broadcasterRequested = false;
14397
+ /** 서버가 정한 방송자 역할 (`joined` 의 is_broadcaster) */
14398
+ this.broadcasterGranted = false;
14399
+ /** 비공개 룸 허용 목록 요청 (이 연결에서만 쓴다) */
14400
+ this.allowedMemberIds = null;
14401
+ this.privateRoom = false;
14402
+ this.warningListeners = [];
14169
14403
  this.localStream = null;
14170
14404
  this.channelType = "interactive";
14171
14405
  // 피어 연결 관리
@@ -14219,7 +14453,10 @@ var WebRTCAPI = class _WebRTCAPI {
14219
14453
  this.setState("connecting");
14220
14454
  this.currentRoomId = options.roomId;
14221
14455
  this.currentUserId = options.userId || null;
14222
- this.isBroadcaster = options.isBroadcaster || false;
14456
+ this.broadcasterRequested = options.isBroadcaster || false;
14457
+ this.broadcasterGranted = false;
14458
+ this.allowedMemberIds = options.allowedMemberIds ? [...options.allowedMemberIds] : null;
14459
+ this.privateRoom = false;
14223
14460
  this.localStream = options.localStream || null;
14224
14461
  if (this.iceServers.length === 0) {
14225
14462
  try {
@@ -14230,9 +14467,35 @@ var WebRTCAPI = class _WebRTCAPI {
14230
14467
  }
14231
14468
  return this.connectWebSocket();
14232
14469
  }
14233
- connectWebSocket() {
14470
+ async connectWebSocket() {
14471
+ const before = this.wsGeneration;
14472
+ const ticket = await this.signalingTicket();
14473
+ if (this.wsGeneration !== before) {
14474
+ throw new Error("\uC5F0\uACB0\uC774 \uCDE8\uC18C\uB418\uC5C8\uC2B5\uB2C8\uB2E4");
14475
+ }
14476
+ return this.openSignalingSocket(ticket);
14477
+ }
14478
+ /**
14479
+ * 연결 시도 한 번에 쓸 1회용 티켓을 받는다(core/ws-ticket.ts). 브라우저 WebSocket 은 헤더를 못 붙여 예전에는 멤버 토큰
14480
+ * (`?access_token=`)과 공개 키(`?public_key=`)를 URL 에 실었다 — 이제 헤더로 티켓을 받아 `?ticket=` 만 싣는다. 서버가
14481
+ * 티켓을 모르거나(구버전) 잠시 못 주면 null 이고 예전처럼 붙는다. 발급이 자격증명을 거부하면 그 이유로 던진다.
14482
+ */
14483
+ async signalingTicket() {
14484
+ const accessToken = this.http.getAccessToken();
14485
+ const publicKey = accessToken ? void 0 : this.http.getPublicKey();
14486
+ if (!accessToken && !publicKey || !this.appId) return null;
14487
+ return fetchWsTicket(
14488
+ ticketUrl(
14489
+ this.webrtcUrl,
14490
+ `/v1/apps/${encodeURIComponent(this.appId)}/signaling/ws-ticket`
14491
+ ),
14492
+ "webrtc.signaling",
14493
+ accessToken ? { kind: "access_token", value: accessToken } : { kind: "public_key", value: publicKey }
14494
+ );
14495
+ }
14496
+ openSignalingSocket(ticket) {
14234
14497
  return new Promise((resolve, reject) => {
14235
- const wsUrl = this.buildWebSocketUrl();
14498
+ const wsUrl = this.buildWebSocketUrl(ticket);
14236
14499
  const generation = ++this.wsGeneration;
14237
14500
  const ws = new WebSocket(wsUrl);
14238
14501
  this.ws = ws;
@@ -14251,13 +14514,16 @@ var WebRTCAPI = class _WebRTCAPI {
14251
14514
  return;
14252
14515
  }
14253
14516
  this.reconnectAttempts = 0;
14517
+ const joinData = {
14518
+ is_broadcaster: this.broadcasterRequested
14519
+ };
14520
+ if (this.allowedMemberIds) {
14521
+ joinData.allowed_member_ids = this.allowedMemberIds;
14522
+ }
14254
14523
  this.sendSignaling({
14255
14524
  type: "join",
14256
14525
  room_id: this.currentRoomId,
14257
- data: {
14258
- user_id: this.currentUserId,
14259
- is_broadcaster: this.isBroadcaster
14260
- }
14526
+ data: joinData
14261
14527
  });
14262
14528
  };
14263
14529
  ws.onmessage = async (event) => {
@@ -14298,12 +14564,14 @@ var WebRTCAPI = class _WebRTCAPI {
14298
14564
  ws.onerror = null;
14299
14565
  ws.onclose = null;
14300
14566
  }
14301
- buildWebSocketUrl() {
14567
+ buildWebSocketUrl(ticket = null) {
14302
14568
  const wsBase = this.webrtcUrl.replace("https://", "wss://").replace("http://", "ws://");
14303
14569
  const publicKey = this.http.getPublicKey();
14304
14570
  const accessToken = this.http.getAccessToken();
14305
14571
  let authParam = "";
14306
- if (accessToken) {
14572
+ if (ticket) {
14573
+ authParam = `ticket=${encodeURIComponent(ticket)}`;
14574
+ } else if (accessToken) {
14307
14575
  authParam = `access_token=${encodeURIComponent(accessToken)}`;
14308
14576
  } else if (publicKey) {
14309
14577
  authParam = `public_key=${encodeURIComponent(publicKey)}`;
@@ -14325,6 +14593,8 @@ var WebRTCAPI = class _WebRTCAPI {
14325
14593
  if (data.channel_type) {
14326
14594
  this.channelType = data.channel_type;
14327
14595
  }
14596
+ this.broadcasterGranted = data.is_broadcaster === true;
14597
+ this.privateRoom = data.private === true;
14328
14598
  const peers = data.peers || [];
14329
14599
  for (const peer of peers) {
14330
14600
  if (peer.peer_id !== this.currentPeerId) {
@@ -14365,9 +14635,16 @@ var WebRTCAPI = class _WebRTCAPI {
14365
14635
  await this.handleICECandidate(msg.peer_id, msg.candidate);
14366
14636
  }
14367
14637
  break;
14638
+ case "warning": {
14639
+ const data = msg.data && typeof msg.data === "object" ? msg.data : {};
14640
+ this.emitWarning({
14641
+ code: typeof data.code === "string" ? data.code : "unknown",
14642
+ message: typeof data.message === "string" ? data.message : ""
14643
+ });
14644
+ break;
14645
+ }
14368
14646
  case "error": {
14369
- const errorMsg = typeof msg.data === "string" ? msg.data : "Unknown error";
14370
- const error = new Error(errorMsg);
14647
+ const error = new Error(_WebRTCAPI.signalingErrorMessage(msg.data));
14371
14648
  this.emitError(error);
14372
14649
  reject?.(error);
14373
14650
  break;
@@ -14465,6 +14742,19 @@ var WebRTCAPI = class _WebRTCAPI {
14465
14742
  }
14466
14743
  this.remoteStreams.delete(remotePeerId);
14467
14744
  }
14745
+ /**
14746
+ * 서버 error 메시지의 data 에서 안내 문구를 꺼낸다. 대부분은 문자열이지만 할당량, 동시접속 초과는
14747
+ * `{ error, message, ... }` 객체로 온다.
14748
+ */
14749
+ static signalingErrorMessage(data) {
14750
+ if (typeof data === "string" && data) return data;
14751
+ if (data && typeof data === "object") {
14752
+ const obj = data;
14753
+ if (typeof obj.message === "string" && obj.message) return obj.message;
14754
+ if (typeof obj.error === "string" && obj.error) return obj.error;
14755
+ }
14756
+ return "Unknown error";
14757
+ }
14468
14758
  sendSignaling(msg) {
14469
14759
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
14470
14760
  this.ws.send(JSON.stringify(msg));
@@ -14530,6 +14820,8 @@ var WebRTCAPI = class _WebRTCAPI {
14530
14820
  this.currentRoomId = null;
14531
14821
  this.currentPeerId = null;
14532
14822
  this.localStream = null;
14823
+ this.broadcasterGranted = false;
14824
+ this.privateRoom = false;
14533
14825
  this.setState("disconnected");
14534
14826
  }
14535
14827
  /**
@@ -14547,9 +14839,15 @@ var WebRTCAPI = class _WebRTCAPI {
14547
14839
  async switchRoom(roomId, options = {}) {
14548
14840
  const localStream = options.localStream ?? this.localStream ?? void 0;
14549
14841
  const userId = options.userId ?? this.currentUserId ?? void 0;
14550
- const isBroadcaster = options.isBroadcaster ?? this.isBroadcaster;
14842
+ const isBroadcaster = options.isBroadcaster ?? this.broadcasterRequested;
14551
14843
  this.disconnect();
14552
- await this.connect({ roomId, userId, isBroadcaster, localStream });
14844
+ await this.connect({
14845
+ roomId,
14846
+ userId,
14847
+ isBroadcaster,
14848
+ localStream,
14849
+ allowedMemberIds: options.allowedMemberIds
14850
+ });
14553
14851
  }
14554
14852
  /**
14555
14853
  * 소켓, 룸, 이벤트 리스너, 피어 연결을 **독립적으로** 갖는 새 WebRTC 세션을 만든다.
@@ -14602,6 +14900,19 @@ var WebRTCAPI = class _WebRTCAPI {
14602
14900
  getChannelType() {
14603
14901
  return this.channelType;
14604
14902
  }
14903
+ /**
14904
+ * 서버가 정한 방송자 역할인지 조회한다. 요청(`isBroadcaster: true`)과 다를 수 있다 — 익명 연결이나
14905
+ * 채널의 방송자 역할이 없는 회원은 시청자로 참가한다(`onWarning` 으로 이유가 온다).
14906
+ */
14907
+ isBroadcaster() {
14908
+ return this.broadcasterGranted;
14909
+ }
14910
+ /**
14911
+ * 지금 룸이 회원 허용 목록이 있는 비공개 룸인지 조회한다.
14912
+ */
14913
+ isPrivateRoom() {
14914
+ return this.privateRoom;
14915
+ }
14605
14916
  /**
14606
14917
  * 원격 스트림 조회
14607
14918
  */
@@ -14672,6 +14983,24 @@ var WebRTCAPI = class _WebRTCAPI {
14672
14983
  this.errorListeners = this.errorListeners.filter((cb) => cb !== callback);
14673
14984
  };
14674
14985
  }
14986
+ /**
14987
+ * 서버가 join 요청을 바꿨다는 알림 이벤트 (예: 방송자 요청을 시청자로 낮춤)
14988
+ *
14989
+ * @example
14990
+ * ```typescript
14991
+ * cb.webrtc.onWarning(({ code, message }) => {
14992
+ * if (code === 'broadcaster_role_required') showToast(message)
14993
+ * })
14994
+ * ```
14995
+ */
14996
+ onWarning(callback) {
14997
+ this.warningListeners.push(callback);
14998
+ return () => {
14999
+ this.warningListeners = this.warningListeners.filter(
15000
+ (cb) => cb !== callback
15001
+ );
15002
+ };
15003
+ }
14675
15004
  /**
14676
15005
  * 피어 참가 이벤트
14677
15006
  */
@@ -14717,6 +15046,9 @@ var WebRTCAPI = class _WebRTCAPI {
14717
15046
  emitError(error) {
14718
15047
  this.errorListeners.forEach((cb) => cb(error));
14719
15048
  }
15049
+ emitWarning(warning) {
15050
+ this.warningListeners.forEach((cb) => cb(warning));
15051
+ }
14720
15052
  emitPeerJoined(peerId, info) {
14721
15053
  this.peerJoinedListeners.forEach((cb) => cb(peerId, info));
14722
15054
  }
@@ -14897,11 +15229,35 @@ var WebSocketTransport = class {
14897
15229
  this.onClose = onClose;
14898
15230
  this.onError = onError;
14899
15231
  }
14900
- connect() {
15232
+ async connect() {
15233
+ const ticket = await this.connectionTicket();
15234
+ return this.openSocket(ticket);
15235
+ }
15236
+ /**
15237
+ * 연결 시도 한 번에 쓸 1회용 티켓(core/ws-ticket.ts). 서버가 티켓을 모르거나(구버전) 잠시 못 주면 null 이고 예전처럼
15238
+ * 자격증명을 URL 에 싣는다. 발급이 자격증명을 거부하면 {@link GameError} 로 던진다.
15239
+ */
15240
+ async connectionTicket() {
15241
+ const { accessToken, publicKey, appId } = this.config;
15242
+ if (!accessToken && !publicKey || !appId) return null;
15243
+ const baseUrl = this.config.gameServerUrl || "wss://game.connectbase.world";
15244
+ try {
15245
+ return await fetchWsTicket(
15246
+ ticketUrl(baseUrl, `/v1/game/${encodeURIComponent(appId)}/ws-ticket`),
15247
+ "game.ws",
15248
+ accessToken ? { kind: "access_token", value: accessToken } : { kind: "public_key", value: publicKey }
15249
+ );
15250
+ } catch (e) {
15251
+ if (e instanceof WsTicketError) {
15252
+ throw new GameError({ code: e.code, message: e.message });
15253
+ }
15254
+ throw e;
15255
+ }
15256
+ }
15257
+ openSocket(ticket) {
14901
15258
  return new Promise((resolve, reject) => {
14902
- const url = this.buildUrl();
14903
15259
  try {
14904
- this.ws = new WebSocket(url);
15260
+ this.ws = new WebSocket(this.buildUrl(ticket));
14905
15261
  this.ws.binaryType = "arraybuffer";
14906
15262
  } catch (error) {
14907
15263
  reject(error);
@@ -14931,16 +15287,20 @@ var WebSocketTransport = class {
14931
15287
  this.ws.addEventListener("message", onMessage);
14932
15288
  });
14933
15289
  }
14934
- buildUrl() {
15290
+ buildUrl(ticket = null) {
14935
15291
  const baseUrl = this.config.gameServerUrl || "wss://game.connectbase.world";
14936
15292
  const wsUrl = baseUrl.replace(/^http/, "ws");
14937
15293
  const params = new URLSearchParams();
14938
15294
  params.set("client_id", requireClientId(this.config.clientId));
14939
- if (this.config.publicKey) {
14940
- params.set("public_key", this.config.publicKey);
14941
- }
14942
- if (this.config.accessToken) {
14943
- params.set("token", this.config.accessToken);
15295
+ if (ticket) {
15296
+ params.set("ticket", ticket);
15297
+ } else {
15298
+ if (this.config.publicKey) {
15299
+ params.set("public_key", this.config.publicKey);
15300
+ }
15301
+ if (this.config.accessToken) {
15302
+ params.set("token", this.config.accessToken);
15303
+ }
14944
15304
  }
14945
15305
  if (!this.config.appId) {
14946
15306
  throw new Error(
@@ -15619,6 +15979,7 @@ export {
15619
15979
  RolesAPI,
15620
15980
  SessionManager,
15621
15981
  VideoProcessingError,
15982
+ WsTicketError,
15622
15983
  index_default as default,
15623
15984
  detectInAppBrowser,
15624
15985
  escapeToExternalBrowser,