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.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;
@@ -308,7 +309,7 @@ var RealtimeConnectionError = class extends Error {
308
309
  }
309
310
  };
310
311
  function isRetryableRealtimeRejection(code) {
311
- return code === "connection_limit_exceeded" || code === "server_at_capacity" || code === "rate_limited" || code === "auth_unavailable" || code === "internal_error";
312
+ 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";
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",
@@ -4645,15 +4744,52 @@ var DatabaseAPI = class {
4645
4744
  );
4646
4745
  if (fresh) opts.accessToken = fresh;
4647
4746
  }
4648
- return this.doRealtimeConnect();
4747
+ let ticket;
4748
+ try {
4749
+ ticket = await this.realtimeTicket();
4750
+ } catch (e) {
4751
+ if (e instanceof WsTicketError) {
4752
+ if (e.status === 429 && this.realtimeOptions) {
4753
+ this.attemptRealtimeReconnect();
4754
+ } else {
4755
+ this.setRealtimeState("disconnected");
4756
+ }
4757
+ const err = new RealtimeConnectionError({
4758
+ code: e.code,
4759
+ message: e.message,
4760
+ closeCode: 4e3 + e.status,
4761
+ retryAfterMs: e.retryAfterMs
4762
+ });
4763
+ this.realtimeOnError?.(err);
4764
+ throw err;
4765
+ }
4766
+ throw e;
4767
+ }
4768
+ return this.doRealtimeConnect(ticket);
4649
4769
  }
4650
- doRealtimeConnect() {
4770
+ /**
4771
+ * 연결 시도 한 번에 쓸 1회용 티켓을 받는다(core/ws-ticket.ts). 브라우저 WebSocket 은 헤더를 못 붙여 예전에는 멤버 토큰을
4772
+ * URL(`?access_token=`)에 실었다 — 이제 헤더로 티켓을 받아 `?ticket=` 만 싣는다. 서버가 티켓을 모르거나(구버전) 잠시 못
4773
+ * 주면 null 이고 예전처럼 붙는다. cross-app OAuth 토큰도 같은 경로다(서버가 티켓에서 토큰을 되살려 같은 검증을 한다).
4774
+ */
4775
+ async realtimeTicket() {
4776
+ const opts = this.realtimeOptions;
4777
+ if (!opts?.accessToken) return null;
4778
+ const baseUrl = opts.dataServerUrl || this.http.getBaseUrl();
4779
+ return fetchWsTicket(
4780
+ ticketUrl(baseUrl, "/v1/database/realtime/ws-ticket"),
4781
+ "data.realtime",
4782
+ { kind: "access_token", value: opts.accessToken },
4783
+ { timeoutMs: REALTIME_CONNECT_TIMEOUT_MS }
4784
+ );
4785
+ }
4786
+ doRealtimeConnect(ticket = null) {
4651
4787
  if (!this.realtimeOptions)
4652
4788
  return Promise.reject(new Error("No realtime options"));
4653
4789
  this.setRealtimeState("connecting");
4654
4790
  const baseUrl = this.realtimeOptions.dataServerUrl || this.http.getBaseUrl();
4655
4791
  const wsUrl = baseUrl.replace(/^http/, "ws");
4656
- let url = `${wsUrl}/v1/database/realtime/ws?access_token=${encodeURIComponent(this.realtimeOptions.accessToken)}`;
4792
+ let url = ticket ? `${wsUrl}/v1/database/realtime/ws?ticket=${encodeURIComponent(ticket)}` : `${wsUrl}/v1/database/realtime/ws?access_token=${encodeURIComponent(this.realtimeOptions.accessToken)}`;
4657
4793
  this.realtimeTokenRefresh = this.http.canRefreshMemberToken(
4658
4794
  this.realtimeOptions.accessToken
4659
4795
  );
@@ -4672,7 +4808,7 @@ var DatabaseAPI = class {
4672
4808
  this.setRealtimeState("disconnected");
4673
4809
  reject(new Error("Connection timeout"));
4674
4810
  }
4675
- }, 15e3);
4811
+ }, REALTIME_CONNECT_TIMEOUT_MS);
4676
4812
  this.realtimeWs.onopen = () => {
4677
4813
  if (!settled) {
4678
4814
  settled = true;
@@ -5213,7 +5349,7 @@ var EndpointAPI = class {
5213
5349
  );
5214
5350
  }
5215
5351
  const baseUrl = this.http.getBaseUrl().replace(/\/+$/, "");
5216
- const ticketUrl = `${baseUrl}/v1/proxy/${encodeURIComponent(label)}/ws-ticket`;
5352
+ const ticketUrl2 = `${baseUrl}/v1/proxy/${encodeURIComponent(label)}/ws-ticket`;
5217
5353
  const ticketHeaders = new Headers();
5218
5354
  const pk = this.http.getPublicKey();
5219
5355
  if (!pk) {
@@ -5222,7 +5358,7 @@ var EndpointAPI = class {
5222
5358
  );
5223
5359
  }
5224
5360
  ticketHeaders.set("X-Public-Key", pk);
5225
- const ticketRes = await fetch(ticketUrl, {
5361
+ const ticketRes = await fetch(ticketUrl2, {
5226
5362
  method: "POST",
5227
5363
  headers: ticketHeaders,
5228
5364
  signal: opts.signal
@@ -5920,20 +6056,54 @@ var GameRoom = class {
5920
6056
  * 게임 서버에 연결
5921
6057
  */
5922
6058
  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;
6059
+ if (this.ws?.readyState === WebSocket.OPEN) {
6060
+ return Promise.resolve();
6061
+ }
6062
+ if (!this.config.appId) {
6063
+ return Promise.reject(
6064
+ new Error(
6065
+ "cb.game: appId is required to connect. Pass it to `new ConnectBase({ appId })` or per-client via `cb.game.createClient({ appId, clientId })`."
6066
+ )
6067
+ );
6068
+ }
6069
+ if (!this.config.clientId) {
6070
+ return Promise.reject(
6071
+ new Error(
6072
+ "cb.game: clientId is required to build a game connection URL. Pass it via `cb.game.createClient({ appId, clientId })`."
6073
+ )
6074
+ );
6075
+ }
6076
+ return this.connectionTicket().then(
6077
+ (ticket) => this.openSocket(roomId, ticket)
6078
+ );
6079
+ }
6080
+ /**
6081
+ * 연결 시도 한 번에 쓸 1회용 티켓을 받는다(core/ws-ticket.ts). 브라우저 WebSocket 은 헤더를 못 붙여 예전에는 멤버
6082
+ * 토큰(`?token=`)과 공개 키(`?public_key=`)를 URL 에 실었다 — 이제 헤더로 티켓을 받아 `?ticket=` 만 싣는다. 서버가
6083
+ * 티켓을 모르거나(구버전) 잠시 못 주면 null 이고 예전처럼 붙는다. 발급이 자격증명을 거부하면 {@link GameError} 로 던진다.
6084
+ */
6085
+ async connectionTicket() {
6086
+ const { accessToken, publicKey, appId, gameServerUrl } = this.config;
6087
+ if (!accessToken && !publicKey || !appId || !gameServerUrl) return null;
6088
+ try {
6089
+ return await fetchWsTicket(
6090
+ ticketUrl(
6091
+ gameServerUrl,
6092
+ `/v1/game/${encodeURIComponent(appId)}/ws-ticket`
6093
+ ),
6094
+ "game.ws",
6095
+ accessToken ? { kind: "access_token", value: accessToken } : { kind: "public_key", value: publicKey }
6096
+ );
6097
+ } catch (e) {
6098
+ if (e instanceof WsTicketError) {
6099
+ throw new GameError({ code: e.code, message: e.message });
5935
6100
  }
5936
- const url = this.buildConnectionUrl(roomId);
6101
+ throw e;
6102
+ }
6103
+ }
6104
+ openSocket(roomId, ticket) {
6105
+ return new Promise((resolve, reject) => {
6106
+ const url = this.buildConnectionUrl(roomId, ticket);
5937
6107
  this.ws = new WebSocket(url);
5938
6108
  const onOpen = () => {
5939
6109
  this._isConnected = true;
@@ -6189,7 +6359,7 @@ var GameRoom = class {
6189
6359
  });
6190
6360
  }
6191
6361
  // Private methods
6192
- buildConnectionUrl(roomId) {
6362
+ buildConnectionUrl(roomId, ticket) {
6193
6363
  const baseUrl = this.config.gameServerUrl;
6194
6364
  const wsUrl = baseUrl.replace(/^http/, "ws");
6195
6365
  const params = new URLSearchParams();
@@ -6202,11 +6372,15 @@ var GameRoom = class {
6202
6372
  if (roomId) {
6203
6373
  params.set("room_id", roomId);
6204
6374
  }
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);
6375
+ if (ticket) {
6376
+ params.set("ticket", ticket);
6377
+ } else {
6378
+ if (this.config.publicKey) {
6379
+ params.set("public_key", this.config.publicKey);
6380
+ }
6381
+ if (this.config.accessToken) {
6382
+ params.set("token", this.config.accessToken);
6383
+ }
6210
6384
  }
6211
6385
  if (!this.config.appId) {
6212
6386
  throw new Error(
@@ -10063,7 +10237,14 @@ var ConnectCancelledError = class extends Error {
10063
10237
  }
10064
10238
  };
10065
10239
  var MAX_HANDSHAKE_RETRIES = 2;
10066
- var RETRY_IN_CONNECT_CODES = /* @__PURE__ */ new Set(["rate_limited", "auth_unavailable"]);
10240
+ var RETRY_IN_CONNECT_CODES = /* @__PURE__ */ new Set([
10241
+ "rate_limited",
10242
+ "auth_unavailable",
10243
+ // 1회용 티켓이 업그레이드에서 거부됐다(만료, 이미 쓰임, 저장소 일시 장애). 다음 시도는 새 티켓을 받거나, 저장소가
10244
+ // 없으면 예전 방식으로 붙는다(core/ws-ticket.ts).
10245
+ "invalid_ws_ticket",
10246
+ "ws_ticket_unavailable"
10247
+ ]);
10067
10248
  var CONNECTION_TOKEN_MIN_VALIDITY_SEC = 600;
10068
10249
  var CONNECT_TOKEN_MIN_VALIDITY_SEC = 60;
10069
10250
  var AUTH_REFRESH_RETRY_MS = 3e4;
@@ -11178,7 +11359,10 @@ var RealtimeAPI = class {
11178
11359
  for (let attempt = 1; ; attempt++) {
11179
11360
  try {
11180
11361
  await this.prepareConnectToken();
11181
- await this.doConnect(Math.max(deadline - Date.now(), 1));
11362
+ const ticket = await this.connectTicket(
11363
+ Math.max(deadline - Date.now(), 1)
11364
+ );
11365
+ await this.doConnect(Math.max(deadline - Date.now(), 1), ticket);
11182
11366
  return;
11183
11367
  } catch (e) {
11184
11368
  let wait = null;
@@ -11221,7 +11405,39 @@ var RealtimeAPI = class {
11221
11405
  this.initialConnecting = false;
11222
11406
  }
11223
11407
  }
11224
- async doConnect(timeoutMs = this.options.timeout) {
11408
+ /**
11409
+ * 연결 시도 한 번에 쓸 1회용 티켓을 받는다(core/ws-ticket.ts). 브라우저 WebSocket 은 헤더를 못 붙여 예전에는 멤버
11410
+ * 토큰과 공개 키를 URL 에 실었다 — 이제 헤더로 티켓을 받아 `?ticket=` 만 싣는다. 티켓은 한 번만 쓰이므로 시도마다
11411
+ * 새로 받는다.
11412
+ *
11413
+ * 서버가 티켓을 모르거나(구버전) 잠시 못 주면 null 이고, doConnect 가 예전처럼 자격증명을 URL 에 싣는다. 발급이
11414
+ * 자격증명을 거부하면 업그레이드 거부와 같은 {@link RealtimeConnectionError} 로 던진다 — 재시도 규칙이 같다.
11415
+ */
11416
+ async connectTicket(timeoutMs) {
11417
+ const accessToken = this.options.accessToken;
11418
+ const publicKey = accessToken ? void 0 : this.http.getPublicKey();
11419
+ if (!accessToken && !publicKey) return null;
11420
+ try {
11421
+ return await fetchWsTicket(
11422
+ ticketUrl(this.socketUrl, "/v1/realtime/ws-ticket"),
11423
+ "socket.realtime",
11424
+ accessToken ? { kind: "access_token", value: accessToken } : { kind: "public_key", value: publicKey },
11425
+ { timeoutMs }
11426
+ );
11427
+ } catch (e) {
11428
+ if (e instanceof WsTicketError) {
11429
+ this.log(`WebSocket ticket rejected: ${e.code} (${e.status})`);
11430
+ throw new RealtimeConnectionError({
11431
+ code: e.code,
11432
+ message: e.message,
11433
+ closeCode: 4e3 + e.status,
11434
+ retryAfterMs: e.retryAfterMs
11435
+ });
11436
+ }
11437
+ throw e;
11438
+ }
11439
+ }
11440
+ async doConnect(timeoutMs = this.options.timeout, ticket = null) {
11225
11441
  return new Promise((resolve, reject) => {
11226
11442
  this.state = "connecting";
11227
11443
  this.notifyStateChange();
@@ -11230,9 +11446,10 @@ var RealtimeAPI = class {
11230
11446
  const usingDirect = !!directWsBase;
11231
11447
  const wsUrl = (directWsBase ?? this.socketUrl).replace(/^http/, "ws");
11232
11448
  let url;
11449
+ const auth = ticket ? `ticket=${encodeURIComponent(ticket)}` : void 0;
11233
11450
  this.tokenRefreshNegotiated = false;
11234
11451
  if (this.options.accessToken) {
11235
- url = `${wsUrl}/v1/realtime/auth?access_token=${encodeURIComponent(this.options.accessToken)}&client_id=${this.clientId}`;
11452
+ url = `${wsUrl}/v1/realtime/auth?${auth ?? `access_token=${encodeURIComponent(this.options.accessToken)}`}&client_id=${this.clientId}`;
11236
11453
  if (this.http.canRefreshMemberToken(this.options.accessToken)) {
11237
11454
  this.tokenRefreshNegotiated = true;
11238
11455
  url += "&token_refresh=1";
@@ -11248,7 +11465,7 @@ var RealtimeAPI = class {
11248
11465
  reject(error);
11249
11466
  return;
11250
11467
  }
11251
- url = `${wsUrl}/v1/realtime/auth?public_key=${encodeURIComponent(publicKey)}&client_id=${this.clientId}`;
11468
+ url = `${wsUrl}/v1/realtime/auth?${auth ?? `public_key=${encodeURIComponent(publicKey)}`}&client_id=${this.clientId}`;
11252
11469
  this.log("Using API Key authentication");
11253
11470
  }
11254
11471
  if (this.userId) {
@@ -11728,7 +11945,9 @@ var RealtimeAPI = class {
11728
11945
  try {
11729
11946
  await this.prepareConnectToken();
11730
11947
  if (this.state !== "reconnecting") return;
11731
- await this.doConnect();
11948
+ const ticket = await this.connectTicket(this.options.timeout);
11949
+ if (this.state !== "reconnecting") return;
11950
+ await this.doConnect(void 0, ticket);
11732
11951
  this.log("Reconnected successfully, restoring subscriptions...");
11733
11952
  const snapshot = this.pendingRestore;
11734
11953
  this.pendingRestore = null;
@@ -12709,32 +12928,41 @@ var SubscriptionAPI = class {
12709
12928
  * 구독 생성
12710
12929
  * 정기결제 구독을 생성합니다.
12711
12930
  *
12712
- * @param data - 구독 정보
12713
- * @returns 생성된 구독 정보
12931
+ * **금액과 요금제는 서버가 정합니다.** 브라우저에서는 콘솔 가격표의 정기 상품 `product_id` 만 보내고, 서버가
12932
+ * 금액, 통화, 주기, 플랜명, 무료 체험, MoR PG 가격 ref 를 상품에서 정합니다. 금액을 직접 보내는 형태(레거시)는
12933
+ * 결제 설정의 금액 출처가 `client` 인 앱이거나 서버(시크릿 키, service_role 함수)에서 부를 때만 받습니다.
12934
+ *
12935
+ * @param data - 가격표 정기 상품(`product_id`) 또는 금액과 요금제, 빌링키, 고객 정보 등
12936
+ * @returns 생성된 구독 정보 (`product_id` 포함)
12714
12937
  *
12715
12938
  * @example
12716
12939
  * ```typescript
12940
+ * // 가격표 구독 (권장) — 금액, 주기, 체험 기간은 콘솔 가격표의 premium_month 가 정한다
12717
12941
  * const subscription = await client.subscription.create({
12718
12942
  * billing_key_id: 'bk_xxxxx',
12719
- * plan_name: '프리미엄 플랜',
12720
- * amount: 9900,
12721
- * billing_cycle: 'monthly',
12722
- * billing_day: 15, // 매월 15일 결제
12723
- * trial_days: 7 // 7일 무료 체험
12943
+ * product_id: 'premium_month',
12724
12944
  * })
12725
12945
  *
12726
- * // MoR(dodo/paddle) 구독 + 할인 코드
12946
+ * // MoR(dodo/paddle) 구독 + 할인 코드 — PG 가격 ref 도 상품의 provider_price_refs 에서 서버가 고른다
12727
12947
  * const promo = await client.subscription.create({
12948
+ * product_id: 'premium_month',
12949
+ * discount_code: 'LAUNCH50', // 첫 결제에 적용 (몇 주기까지인지는 PG 쿠폰 설정이 결정)
12950
+ * })
12951
+ *
12952
+ * // 금액 직접 지정 (레거시 앱, 서버 호출) — 가격표 전용 앱에서 브라우저가 보내면 403 client_amount_not_allowed
12953
+ * const legacy = await client.subscription.create({
12954
+ * billing_key_id: 'bk_xxxxx',
12728
12955
  * plan_name: '프리미엄 플랜',
12729
12956
  * amount: 9900,
12730
12957
  * billing_cycle: 'monthly',
12731
- * provider_price_ref: 'pdt_xxxxx', // PG 카탈로그의 recurring 상품
12732
- * discount_code: 'LAUNCH50', // 첫 결제에 적용 (몇 주기까지인지는 PG 쿠폰 설정이 결정)
12733
- * trial_days: 14, // Dodo 만 지원 — Paddle 은 400 trial_days_unsupported
12734
12958
  * })
12735
12959
  * ```
12736
12960
  *
12737
12961
  * @remarks
12962
+ * 권한은 구독의 `product_id` 로 지급합니다(`metadata` 는 클라이언트 값). 가격표 전용 앱의 브라우저 호출은
12963
+ * `start_now: false` 와 `billing_day` 를 쓸 수 없습니다(403 `client_schedule_not_allowed`) — 둘 다 한 번 낸 돈으로 쓰는
12964
+ * 기간을 늘립니다. 무료 체험은 상품의 `trial_days` 가 정합니다.
12965
+ *
12738
12966
  * 할인 코드(`discount_code`)와 `trial_days` 의 프로바이더 지원 범위가 다릅니다:
12739
12967
  * - **Dodo**: 할인 코드 O, 결제창 쿠폰 입력 토글 O, 구독별 체험 일수 O
12740
12968
  * - **Paddle**: 할인 코드 O(코드→내부 ID 자동 해소), 쿠폰 입력칸은 항상 노출(숨김 불가),
@@ -12806,9 +13034,9 @@ var SubscriptionAPI = class {
12806
13034
  *
12807
13035
  * @example
12808
13036
  * ```typescript
13037
+ * // 서버(시크릿 키, service_role 함수)에서 다른 가격표 상품으로 바꾸기 — 다음 청구부터 새 금액
12809
13038
  * await client.subscription.update(subscriptionId, {
12810
- * plan_name: '엔터프라이즈 플랜',
12811
- * amount: 29900
13039
+ * product_id: 'enterprise_month',
12812
13040
  * })
12813
13041
  *
12814
13042
  * // 다음 결제일을 특정 날짜로 옮기기 (기간권·보상)
@@ -12994,11 +13222,10 @@ var SubscriptionAPI = class {
12994
13222
  *
12995
13223
  * @example
12996
13224
  * ```typescript
13225
+ * // 가격표 정기 상품으로 바꾼다 — 새 PG 가격 ref 와 플랜명은 서버가 상품에서 정한다
12997
13226
  * await client.subscription.changePlan(subscriptionId, {
12998
- * provider_price_ref: 'pri_family_monthly', // Dodo 는 'pdt_*'
12999
- * plan_name: '가족 플랜',
13000
- * amount: 24900,
13001
- * proration_mode: 'prorated_immediately',
13227
+ * product_id: 'family_month',
13228
+ * proration_mode: 'prorated_immediately', // 브라우저는 지금 정산하는 모드만
13002
13229
  * })
13003
13230
  * ```
13004
13231
  */
@@ -13031,7 +13258,7 @@ var SubscriptionAPI = class {
13031
13258
  * @example
13032
13259
  * ```typescript
13033
13260
  * const preview = await client.subscription.previewChangePlan(subscriptionId, {
13034
- * provider_price_ref: 'pri_family_monthly',
13261
+ * product_id: 'family_month',
13035
13262
  * })
13036
13263
  * // 통화를 함께 포맷한다 — 금액만 쓰면 다통화에서 거짓 금액이 된다
13037
13264
  * format(preview.immediate_charge_amount, preview.currency)
@@ -13085,10 +13312,11 @@ var SubscriptionAPI = class {
13085
13312
  *
13086
13313
  * @example
13087
13314
  * ```typescript
13315
+ * // 가격표 단건 상품으로 청구 — 금액과 상품명은 서버가 정한다
13088
13316
  * const result = await client.subscription.chargeWithBillingKey({
13089
13317
  * billing_key_id: 'bk_xxxxx',
13090
- * amount: 15000,
13091
- * order_name: '추가 결제'
13318
+ * product_id: 'gem_100',
13319
+ * quantity: 2,
13092
13320
  * })
13093
13321
  *
13094
13322
  * if (result.status === 'done') {
@@ -13322,8 +13550,8 @@ var SupportAPI = class {
13322
13550
  }
13323
13551
  };
13324
13552
  function detectSdkVersion() {
13325
- if ("6.12.0") {
13326
- return "6.12.0";
13553
+ if ("6.14.0") {
13554
+ return "6.14.0";
13327
13555
  }
13328
13556
  return "unknown";
13329
13557
  }
@@ -14217,7 +14445,14 @@ var WebRTCAPI = class _WebRTCAPI {
14217
14445
  this.currentRoomId = null;
14218
14446
  this.currentPeerId = null;
14219
14447
  this.currentUserId = null;
14220
- this.isBroadcaster = false;
14448
+ /** connect 가 요청한 방송자 역할 (switchRoom 이 이어받는다) */
14449
+ this.broadcasterRequested = false;
14450
+ /** 서버가 정한 방송자 역할 (`joined` 의 is_broadcaster) */
14451
+ this.broadcasterGranted = false;
14452
+ /** 비공개 룸 허용 목록 요청 (이 연결에서만 쓴다) */
14453
+ this.allowedMemberIds = null;
14454
+ this.privateRoom = false;
14455
+ this.warningListeners = [];
14221
14456
  this.localStream = null;
14222
14457
  this.channelType = "interactive";
14223
14458
  // 피어 연결 관리
@@ -14271,7 +14506,10 @@ var WebRTCAPI = class _WebRTCAPI {
14271
14506
  this.setState("connecting");
14272
14507
  this.currentRoomId = options.roomId;
14273
14508
  this.currentUserId = options.userId || null;
14274
- this.isBroadcaster = options.isBroadcaster || false;
14509
+ this.broadcasterRequested = options.isBroadcaster || false;
14510
+ this.broadcasterGranted = false;
14511
+ this.allowedMemberIds = options.allowedMemberIds ? [...options.allowedMemberIds] : null;
14512
+ this.privateRoom = false;
14275
14513
  this.localStream = options.localStream || null;
14276
14514
  if (this.iceServers.length === 0) {
14277
14515
  try {
@@ -14282,9 +14520,35 @@ var WebRTCAPI = class _WebRTCAPI {
14282
14520
  }
14283
14521
  return this.connectWebSocket();
14284
14522
  }
14285
- connectWebSocket() {
14523
+ async connectWebSocket() {
14524
+ const before = this.wsGeneration;
14525
+ const ticket = await this.signalingTicket();
14526
+ if (this.wsGeneration !== before) {
14527
+ throw new Error("\uC5F0\uACB0\uC774 \uCDE8\uC18C\uB418\uC5C8\uC2B5\uB2C8\uB2E4");
14528
+ }
14529
+ return this.openSignalingSocket(ticket);
14530
+ }
14531
+ /**
14532
+ * 연결 시도 한 번에 쓸 1회용 티켓을 받는다(core/ws-ticket.ts). 브라우저 WebSocket 은 헤더를 못 붙여 예전에는 멤버 토큰
14533
+ * (`?access_token=`)과 공개 키(`?public_key=`)를 URL 에 실었다 — 이제 헤더로 티켓을 받아 `?ticket=` 만 싣는다. 서버가
14534
+ * 티켓을 모르거나(구버전) 잠시 못 주면 null 이고 예전처럼 붙는다. 발급이 자격증명을 거부하면 그 이유로 던진다.
14535
+ */
14536
+ async signalingTicket() {
14537
+ const accessToken = this.http.getAccessToken();
14538
+ const publicKey = accessToken ? void 0 : this.http.getPublicKey();
14539
+ if (!accessToken && !publicKey || !this.appId) return null;
14540
+ return fetchWsTicket(
14541
+ ticketUrl(
14542
+ this.webrtcUrl,
14543
+ `/v1/apps/${encodeURIComponent(this.appId)}/signaling/ws-ticket`
14544
+ ),
14545
+ "webrtc.signaling",
14546
+ accessToken ? { kind: "access_token", value: accessToken } : { kind: "public_key", value: publicKey }
14547
+ );
14548
+ }
14549
+ openSignalingSocket(ticket) {
14286
14550
  return new Promise((resolve, reject) => {
14287
- const wsUrl = this.buildWebSocketUrl();
14551
+ const wsUrl = this.buildWebSocketUrl(ticket);
14288
14552
  const generation = ++this.wsGeneration;
14289
14553
  const ws = new WebSocket(wsUrl);
14290
14554
  this.ws = ws;
@@ -14303,13 +14567,16 @@ var WebRTCAPI = class _WebRTCAPI {
14303
14567
  return;
14304
14568
  }
14305
14569
  this.reconnectAttempts = 0;
14570
+ const joinData = {
14571
+ is_broadcaster: this.broadcasterRequested
14572
+ };
14573
+ if (this.allowedMemberIds) {
14574
+ joinData.allowed_member_ids = this.allowedMemberIds;
14575
+ }
14306
14576
  this.sendSignaling({
14307
14577
  type: "join",
14308
14578
  room_id: this.currentRoomId,
14309
- data: {
14310
- user_id: this.currentUserId,
14311
- is_broadcaster: this.isBroadcaster
14312
- }
14579
+ data: joinData
14313
14580
  });
14314
14581
  };
14315
14582
  ws.onmessage = async (event) => {
@@ -14350,12 +14617,14 @@ var WebRTCAPI = class _WebRTCAPI {
14350
14617
  ws.onerror = null;
14351
14618
  ws.onclose = null;
14352
14619
  }
14353
- buildWebSocketUrl() {
14620
+ buildWebSocketUrl(ticket = null) {
14354
14621
  const wsBase = this.webrtcUrl.replace("https://", "wss://").replace("http://", "ws://");
14355
14622
  const publicKey = this.http.getPublicKey();
14356
14623
  const accessToken = this.http.getAccessToken();
14357
14624
  let authParam = "";
14358
- if (accessToken) {
14625
+ if (ticket) {
14626
+ authParam = `ticket=${encodeURIComponent(ticket)}`;
14627
+ } else if (accessToken) {
14359
14628
  authParam = `access_token=${encodeURIComponent(accessToken)}`;
14360
14629
  } else if (publicKey) {
14361
14630
  authParam = `public_key=${encodeURIComponent(publicKey)}`;
@@ -14377,6 +14646,8 @@ var WebRTCAPI = class _WebRTCAPI {
14377
14646
  if (data.channel_type) {
14378
14647
  this.channelType = data.channel_type;
14379
14648
  }
14649
+ this.broadcasterGranted = data.is_broadcaster === true;
14650
+ this.privateRoom = data.private === true;
14380
14651
  const peers = data.peers || [];
14381
14652
  for (const peer of peers) {
14382
14653
  if (peer.peer_id !== this.currentPeerId) {
@@ -14417,9 +14688,16 @@ var WebRTCAPI = class _WebRTCAPI {
14417
14688
  await this.handleICECandidate(msg.peer_id, msg.candidate);
14418
14689
  }
14419
14690
  break;
14691
+ case "warning": {
14692
+ const data = msg.data && typeof msg.data === "object" ? msg.data : {};
14693
+ this.emitWarning({
14694
+ code: typeof data.code === "string" ? data.code : "unknown",
14695
+ message: typeof data.message === "string" ? data.message : ""
14696
+ });
14697
+ break;
14698
+ }
14420
14699
  case "error": {
14421
- const errorMsg = typeof msg.data === "string" ? msg.data : "Unknown error";
14422
- const error = new Error(errorMsg);
14700
+ const error = new Error(_WebRTCAPI.signalingErrorMessage(msg.data));
14423
14701
  this.emitError(error);
14424
14702
  reject?.(error);
14425
14703
  break;
@@ -14517,6 +14795,19 @@ var WebRTCAPI = class _WebRTCAPI {
14517
14795
  }
14518
14796
  this.remoteStreams.delete(remotePeerId);
14519
14797
  }
14798
+ /**
14799
+ * 서버 error 메시지의 data 에서 안내 문구를 꺼낸다. 대부분은 문자열이지만 할당량, 동시접속 초과는
14800
+ * `{ error, message, ... }` 객체로 온다.
14801
+ */
14802
+ static signalingErrorMessage(data) {
14803
+ if (typeof data === "string" && data) return data;
14804
+ if (data && typeof data === "object") {
14805
+ const obj = data;
14806
+ if (typeof obj.message === "string" && obj.message) return obj.message;
14807
+ if (typeof obj.error === "string" && obj.error) return obj.error;
14808
+ }
14809
+ return "Unknown error";
14810
+ }
14520
14811
  sendSignaling(msg) {
14521
14812
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
14522
14813
  this.ws.send(JSON.stringify(msg));
@@ -14582,6 +14873,8 @@ var WebRTCAPI = class _WebRTCAPI {
14582
14873
  this.currentRoomId = null;
14583
14874
  this.currentPeerId = null;
14584
14875
  this.localStream = null;
14876
+ this.broadcasterGranted = false;
14877
+ this.privateRoom = false;
14585
14878
  this.setState("disconnected");
14586
14879
  }
14587
14880
  /**
@@ -14599,9 +14892,15 @@ var WebRTCAPI = class _WebRTCAPI {
14599
14892
  async switchRoom(roomId, options = {}) {
14600
14893
  const localStream = options.localStream ?? this.localStream ?? void 0;
14601
14894
  const userId = options.userId ?? this.currentUserId ?? void 0;
14602
- const isBroadcaster = options.isBroadcaster ?? this.isBroadcaster;
14895
+ const isBroadcaster = options.isBroadcaster ?? this.broadcasterRequested;
14603
14896
  this.disconnect();
14604
- await this.connect({ roomId, userId, isBroadcaster, localStream });
14897
+ await this.connect({
14898
+ roomId,
14899
+ userId,
14900
+ isBroadcaster,
14901
+ localStream,
14902
+ allowedMemberIds: options.allowedMemberIds
14903
+ });
14605
14904
  }
14606
14905
  /**
14607
14906
  * 소켓, 룸, 이벤트 리스너, 피어 연결을 **독립적으로** 갖는 새 WebRTC 세션을 만든다.
@@ -14654,6 +14953,19 @@ var WebRTCAPI = class _WebRTCAPI {
14654
14953
  getChannelType() {
14655
14954
  return this.channelType;
14656
14955
  }
14956
+ /**
14957
+ * 서버가 정한 방송자 역할인지 조회한다. 요청(`isBroadcaster: true`)과 다를 수 있다 — 익명 연결이나
14958
+ * 채널의 방송자 역할이 없는 회원은 시청자로 참가한다(`onWarning` 으로 이유가 온다).
14959
+ */
14960
+ isBroadcaster() {
14961
+ return this.broadcasterGranted;
14962
+ }
14963
+ /**
14964
+ * 지금 룸이 회원 허용 목록이 있는 비공개 룸인지 조회한다.
14965
+ */
14966
+ isPrivateRoom() {
14967
+ return this.privateRoom;
14968
+ }
14657
14969
  /**
14658
14970
  * 원격 스트림 조회
14659
14971
  */
@@ -14724,6 +15036,24 @@ var WebRTCAPI = class _WebRTCAPI {
14724
15036
  this.errorListeners = this.errorListeners.filter((cb) => cb !== callback);
14725
15037
  };
14726
15038
  }
15039
+ /**
15040
+ * 서버가 join 요청을 바꿨다는 알림 이벤트 (예: 방송자 요청을 시청자로 낮춤)
15041
+ *
15042
+ * @example
15043
+ * ```typescript
15044
+ * cb.webrtc.onWarning(({ code, message }) => {
15045
+ * if (code === 'broadcaster_role_required') showToast(message)
15046
+ * })
15047
+ * ```
15048
+ */
15049
+ onWarning(callback) {
15050
+ this.warningListeners.push(callback);
15051
+ return () => {
15052
+ this.warningListeners = this.warningListeners.filter(
15053
+ (cb) => cb !== callback
15054
+ );
15055
+ };
15056
+ }
14727
15057
  /**
14728
15058
  * 피어 참가 이벤트
14729
15059
  */
@@ -14769,6 +15099,9 @@ var WebRTCAPI = class _WebRTCAPI {
14769
15099
  emitError(error) {
14770
15100
  this.errorListeners.forEach((cb) => cb(error));
14771
15101
  }
15102
+ emitWarning(warning) {
15103
+ this.warningListeners.forEach((cb) => cb(warning));
15104
+ }
14772
15105
  emitPeerJoined(peerId, info) {
14773
15106
  this.peerJoinedListeners.forEach((cb) => cb(peerId, info));
14774
15107
  }
@@ -14949,11 +15282,35 @@ var WebSocketTransport = class {
14949
15282
  this.onClose = onClose;
14950
15283
  this.onError = onError;
14951
15284
  }
14952
- connect() {
15285
+ async connect() {
15286
+ const ticket = await this.connectionTicket();
15287
+ return this.openSocket(ticket);
15288
+ }
15289
+ /**
15290
+ * 연결 시도 한 번에 쓸 1회용 티켓(core/ws-ticket.ts). 서버가 티켓을 모르거나(구버전) 잠시 못 주면 null 이고 예전처럼
15291
+ * 자격증명을 URL 에 싣는다. 발급이 자격증명을 거부하면 {@link GameError} 로 던진다.
15292
+ */
15293
+ async connectionTicket() {
15294
+ const { accessToken, publicKey, appId } = this.config;
15295
+ if (!accessToken && !publicKey || !appId) return null;
15296
+ const baseUrl = this.config.gameServerUrl || "wss://game.connectbase.world";
15297
+ try {
15298
+ return await fetchWsTicket(
15299
+ ticketUrl(baseUrl, `/v1/game/${encodeURIComponent(appId)}/ws-ticket`),
15300
+ "game.ws",
15301
+ accessToken ? { kind: "access_token", value: accessToken } : { kind: "public_key", value: publicKey }
15302
+ );
15303
+ } catch (e) {
15304
+ if (e instanceof WsTicketError) {
15305
+ throw new GameError({ code: e.code, message: e.message });
15306
+ }
15307
+ throw e;
15308
+ }
15309
+ }
15310
+ openSocket(ticket) {
14953
15311
  return new Promise((resolve, reject) => {
14954
- const url = this.buildUrl();
14955
15312
  try {
14956
- this.ws = new WebSocket(url);
15313
+ this.ws = new WebSocket(this.buildUrl(ticket));
14957
15314
  this.ws.binaryType = "arraybuffer";
14958
15315
  } catch (error) {
14959
15316
  reject(error);
@@ -14983,16 +15340,20 @@ var WebSocketTransport = class {
14983
15340
  this.ws.addEventListener("message", onMessage);
14984
15341
  });
14985
15342
  }
14986
- buildUrl() {
15343
+ buildUrl(ticket = null) {
14987
15344
  const baseUrl = this.config.gameServerUrl || "wss://game.connectbase.world";
14988
15345
  const wsUrl = baseUrl.replace(/^http/, "ws");
14989
15346
  const params = new URLSearchParams();
14990
15347
  params.set("client_id", requireClientId(this.config.clientId));
14991
- if (this.config.publicKey) {
14992
- params.set("public_key", this.config.publicKey);
14993
- }
14994
- if (this.config.accessToken) {
14995
- params.set("token", this.config.accessToken);
15348
+ if (ticket) {
15349
+ params.set("ticket", ticket);
15350
+ } else {
15351
+ if (this.config.publicKey) {
15352
+ params.set("public_key", this.config.publicKey);
15353
+ }
15354
+ if (this.config.accessToken) {
15355
+ params.set("token", this.config.accessToken);
15356
+ }
14996
15357
  }
14997
15358
  if (!this.config.appId) {
14998
15359
  throw new Error(
@@ -15672,6 +16033,7 @@ var index_default = ConnectBase;
15672
16033
  RolesAPI,
15673
16034
  SessionManager,
15674
16035
  VideoProcessingError,
16036
+ WsTicketError,
15675
16037
  detectInAppBrowser,
15676
16038
  escapeToExternalBrowser,
15677
16039
  isWebTransportSupported,