connectbase-client 6.6.0 → 6.7.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
@@ -2074,6 +2074,11 @@ var AuthAPI = class {
2074
2074
  * role: 'editor',
2075
2075
  * custom_data: { level: 5 }
2076
2076
  * })
2077
+ *
2078
+ * // 역할 여러 개 (6.7.0+): role 은 주 역할(RLS auth.role), roles 는 추가 역할 목록을 **통째로 바꾼다**
2079
+ * // ([] 면 비우고, 빼면 그대로). auth.roles, hasRole(), 실시간 publisher_roles 는 role + roles 를 함께 본다.
2080
+ * await cb.auth.adminUpdateMember('member-uuid', { roles: ['performer'] })
2081
+ * // → { role: 'admin', roles: ['performer'], effective_roles: ['admin', 'performer'], ... }
2077
2082
  * ```
2078
2083
  */
2079
2084
  async adminUpdateMember(memberID, fields) {
@@ -7991,6 +7996,38 @@ var ConnectCancelledError = class extends Error {
7991
7996
  }
7992
7997
  };
7993
7998
  var MAX_HANDSHAKE_RETRIES = 2;
7999
+ var RETRY_IN_CONNECT_CODES = /* @__PURE__ */ new Set(["rate_limited", "auth_unavailable"]);
8000
+ var CHANNEL_CATEGORY_RE = /^[A-Za-z0-9_-]{1,128}$/;
8001
+ var CHANNEL_ROOM_RE = /^[A-Za-z0-9._-]{1,128}$/;
8002
+ var MAX_CHANNEL_LENGTH = 256;
8003
+ function toChannel(category, room) {
8004
+ if (room === void 0) return category;
8005
+ const show = (v) => typeof v === "string" ? JSON.stringify(v) : String(v);
8006
+ if (typeof room !== "string" || !CHANNEL_ROOM_RE.test(room)) {
8007
+ throw new Error(
8008
+ `Invalid room ${show(room)}: use 1-128 characters of letters, digits, "-", "_" or "." (the realtime channel is "<category>:<room>").`
8009
+ );
8010
+ }
8011
+ if (typeof category !== "string" || !CHANNEL_CATEGORY_RE.test(category)) {
8012
+ throw new Error(
8013
+ `Invalid category ${show(category)} for a room subscription: use the base category name (1-128 characters of letters, digits, "-" or "_") and pass the room separately with { room }.`
8014
+ );
8015
+ }
8016
+ const channel = `${category}:${room}`;
8017
+ if (channel.length > MAX_CHANNEL_LENGTH) {
8018
+ throw new Error(
8019
+ `Realtime channel "${channel}" is too long (max ${MAX_CHANNEL_LENGTH} characters including ":").`
8020
+ );
8021
+ }
8022
+ return channel;
8023
+ }
8024
+ function roomOf(channel) {
8025
+ const idx = channel.indexOf(":");
8026
+ return idx > 0 && idx < channel.length - 1 ? channel.slice(idx + 1) : void 0;
8027
+ }
8028
+ function toMemberId(value) {
8029
+ return typeof value === "string" && value !== "" ? value : void 0;
8030
+ }
7994
8031
  function jitter(max) {
7995
8032
  return Math.floor(Math.random() * Math.max(0, max));
7996
8033
  }
@@ -8211,31 +8248,46 @@ var RealtimeAPI = class {
8211
8248
  }
8212
8249
  /**
8213
8250
  * 카테고리 구독
8251
+ *
8252
+ * `options.room` 을 주면 그 카테고리의 room 하나를 구독한다 (6.7.0+). 서버 채널 이름은
8253
+ * `${category}:${room}` 이고, 구독 객체의 `send` / `getHistory` / `unsubscribe` / `onMessage`, 재연결 뒤
8254
+ * 다시 구독하기는 모두 이 채널 이름으로 동작한다(`subscription.channel`).
8255
+ *
8256
+ * @example
8257
+ * ```typescript
8258
+ * const stage = await client.realtime.subscribe('concert', { room: 'show-1025' })
8259
+ * stage.channel // 'concert:show-1025'
8260
+ * stage.room // 'show-1025'
8261
+ * stage.info.isPublisher // room 인가 함수가 publisher: true 를 돌려준 로그인 멤버면 true
8262
+ * ```
8214
8263
  */
8215
8264
  async subscribe(category, options = {}) {
8265
+ const channel = toChannel(category, options.room);
8216
8266
  this.assertBidirectional("subscribe");
8217
8267
  if (this.state !== "connected") {
8218
8268
  throw new Error("Not connected. Call connect() first.");
8219
8269
  }
8220
8270
  const requestId = this.generateRequestId();
8221
8271
  const response = await this.sendRequest({
8222
- category,
8272
+ category: channel,
8223
8273
  action: "subscribe",
8224
8274
  request_id: requestId
8225
8275
  });
8226
8276
  const info = toCategoryInfo(response);
8227
8277
  const handlers = [];
8228
- this.subscriptions.set(category, { info, handlers });
8278
+ this.subscriptions.set(channel, { info, handlers });
8229
8279
  const subscription = {
8230
8280
  info,
8281
+ channel,
8282
+ room: roomOf(channel),
8231
8283
  send: async (data, sendOptions) => {
8232
- await this.sendMessage(category, data, sendOptions);
8284
+ await this.sendMessage(channel, data, sendOptions);
8233
8285
  },
8234
8286
  getHistory: async (limit) => {
8235
- return this.getHistory(category, limit ?? options.historyLimit);
8287
+ return this.getHistory(channel, limit ?? options.historyLimit);
8236
8288
  },
8237
8289
  unsubscribe: async () => {
8238
- await this.unsubscribe(category);
8290
+ await this.unsubscribe(channel);
8239
8291
  },
8240
8292
  onMessage: (handler) => {
8241
8293
  handlers.push(handler);
@@ -8249,6 +8301,7 @@ var RealtimeAPI = class {
8249
8301
  }
8250
8302
  /**
8251
8303
  * 구독 해제
8304
+ * @param category 카테고리 이름. room 을 구독했으면 채널 이름(`category:room`, `subscription.channel`)
8252
8305
  */
8253
8306
  async unsubscribe(category) {
8254
8307
  if (this.state !== "connected") {
@@ -8264,7 +8317,7 @@ var RealtimeAPI = class {
8264
8317
  }
8265
8318
  /**
8266
8319
  * 메시지 전송
8267
- * @param category 카테고리 이름
8320
+ * @param category 카테고리 이름. room 을 구독했으면 채널 이름(`category:room`, `subscription.channel`)
8268
8321
  * @param data 전송할 데이터
8269
8322
  * @param options 전송 옵션 (includeSelf: 발신자도 메시지 수신 여부, 기본값 true)
8270
8323
  */
@@ -8285,6 +8338,7 @@ var RealtimeAPI = class {
8285
8338
  }
8286
8339
  /**
8287
8340
  * 히스토리 조회
8341
+ * @param category 카테고리 이름. room 을 구독했으면 채널 이름(`category:room`, `subscription.channel`)
8288
8342
  */
8289
8343
  async getHistory(category, limit) {
8290
8344
  this.assertBidirectional("getHistory");
@@ -8304,6 +8358,7 @@ var RealtimeAPI = class {
8304
8358
  id: m.id,
8305
8359
  category: m.category,
8306
8360
  from: m.from,
8361
+ fromMember: toMemberId(m.from_member),
8307
8362
  data: m.data,
8308
8363
  sentAt: m.sent_at
8309
8364
  })),
@@ -8993,7 +9048,9 @@ var RealtimeAPI = class {
8993
9048
  * 한국 사용자는 미국(LAX)을 거친 ~270ms 로 붙었다(platform-issue 01a15d9d 의 273ms). 응답 헤더는
8994
9049
  * HttpClient 가 평소처럼 학습한다. 광고 대상이 아니면(해외, 킬 스위치) 빈 응답이라 원래 소켓
8995
9050
  * URL 로 간다. 실패하거나 느리면(1.5초) 그냥 넘어간다 — 최적화일 뿐 연결의 전제가 아니다.
8996
- * 세션당 한 번만 묻는다.
9051
+ * 인스턴스당 한 번만 묻고, 같은 baseUrl 의 인스턴스끼리는 진행 중인 조회와 배운 결과를 함께 쓴다
9052
+ * (6.7.0+, HttpClient.primeEdgeHints 참고 — 한 페이지의 인스턴스 10개가 동시에 물어 대부분 1.5초를 넘기던
9053
+ * platform-issue 01a15e81).
8997
9054
  */
8998
9055
  async primeDirectRealtime() {
8999
9056
  if (this.directRealtimePrimed) return;
@@ -9012,12 +9069,17 @@ var RealtimeAPI = class {
9012
9069
  /**
9013
9070
  * 첫 연결을 연결 제한 시간(`timeout`) 안에서 다시 시도한다 (6.6.0+, platform-issue 01a15e24).
9014
9071
  *
9015
- * - 서버가 재시도 시각을 알려 준 거부(`retryAfterMs`, 예: 한 IP 에서 연결이 몰린 `rate_limited`):
9016
- * 그 시각에 지터를 더해 다시 붙는다. 공연 시작처럼 한꺼번에 들어올 때 앱이 재시도를 짜지 않아도 된다.
9072
+ * - 서버가 재시도 시각을 알려 준 거부(`retryAfterMs`, 예: 한 IP 에서 연결이 몰린 `rate_limited`, 인증 서버가
9073
+ * 잠깐 닿지 않는 `auth_unavailable`): 그 시각에 지터를 더해 다시 붙는다. 공연 시작처럼 한꺼번에 들어올 때
9074
+ * 앱이 재시도를 짜지 않아도 된다.
9075
+ * - `rate_limited` / `auth_unavailable` 인데 재시도 시각이 없으면(사유 프레임 없이 close 코드만 왔거나 구버전
9076
+ * 서버) 지수 백오프로 다시 한다. `auth_unavailable` 은 6.6.0 까지 바로 던져, 인증 서버가 몇 초 흔들리는
9077
+ * 동안 들어온 관객이 전부 연결에 실패했다(6.7.0, platform-issue 01a15e81).
9017
9078
  * - 이유 없는 핸드셰이크 실패: 두 번까지 백오프 후 다시 한다. 네트워크가 WebSocket 을 실제로 막으면
9018
9079
  * 매번 실패하고, 앞단의 일시 거부는 다시 하면 붙는다 — 한 번 실패로 SSE 폴백하던 것이 연결 속도
9019
9080
  * 제한을 "네트워크 차단" 으로 오진한 원인이었다.
9020
- * - 그 밖의 거부(자격증명, 동시접속 한도 등)와 시간 초과는 바로 던진다.
9081
+ * - 그 밖의 거부(자격증명, 동시접속 한도 등)와 시간 초과는 바로 던진다. 다시 시도할 시간이 남지 않아도
9082
+ * 마지막 거부를 그대로 던진다(`retryAfterMs` 가 있으면 앱이 그 시각에 다시 부를 수 있다).
9021
9083
  */
9022
9084
  async connectWithRetry() {
9023
9085
  const generation = this.connectGeneration;
@@ -9031,10 +9093,10 @@ var RealtimeAPI = class {
9031
9093
  return;
9032
9094
  } catch (e) {
9033
9095
  let wait = null;
9034
- if (e instanceof RealtimeConnectionError) {
9035
- if (e.retryable && e.retryAfterMs !== void 0) {
9096
+ if (e instanceof RealtimeConnectionError && e.retryable) {
9097
+ if (e.retryAfterMs !== void 0) {
9036
9098
  wait = e.retryAfterMs + jitter(Math.min(100 * 2 ** attempt, 3e3));
9037
- } else if (e.code === "rate_limited") {
9099
+ } else if (RETRY_IN_CONNECT_CODES.has(e.code)) {
9038
9100
  const base = Math.min(500 * 2 ** (attempt - 1), 8e3);
9039
9101
  wait = base / 2 + jitter(base / 2);
9040
9102
  }
@@ -9265,7 +9327,9 @@ var RealtimeAPI = class {
9265
9327
  const message = {
9266
9328
  id: data.id,
9267
9329
  category: data.category,
9330
+ // from 은 클라이언트가 정한 client_id 라 믿을 수 없다. 보낸 사람은 fromMember(서버 검증)로 본다.
9268
9331
  from: data.from,
9332
+ fromMember: toMemberId(data.from_member),
9269
9333
  data: data.data,
9270
9334
  sentAt: data.sent_at,
9271
9335
  fromPublisher: data.from_publisher === true
@@ -11125,8 +11189,8 @@ var SupportAPI = class {
11125
11189
  }
11126
11190
  };
11127
11191
  function detectSdkVersion() {
11128
- if (typeof __SDK_VERSION__ !== "undefined" && __SDK_VERSION__) {
11129
- return __SDK_VERSION__;
11192
+ if ("6.7.0") {
11193
+ return "6.7.0";
11130
11194
  }
11131
11195
  return "unknown";
11132
11196
  }
@@ -12718,6 +12782,63 @@ function isConsolePlatformToken(token) {
12718
12782
  const role = decodeJwtPayload(token)?.role;
12719
12783
  return role === "User" || role === "AdminInvite";
12720
12784
  }
12785
+ var EDGE_HINTS_TTL_MS = 10 * 6e4;
12786
+ var sharedEdgeHints = /* @__PURE__ */ new Map();
12787
+ function lookupEdgeHints(baseUrl, timeoutMs) {
12788
+ const cached = sharedEdgeHints.get(baseUrl);
12789
+ if (cached && Date.now() < cached.expiresAt) return cached.promise;
12790
+ const entry = {
12791
+ promise: fetchEdgeHintAdverts(baseUrl, timeoutMs),
12792
+ // 진행 중에는 만료되지 않는다 — 시간 제한(withAbort) 때문에 timeoutMs 안에 반드시 끝난다. 늦게 합류한
12793
+ // 인스턴스는 남은 시간만 기다린다.
12794
+ expiresAt: Number.POSITIVE_INFINITY
12795
+ };
12796
+ sharedEdgeHints.set(baseUrl, entry);
12797
+ entry.promise.then(
12798
+ () => {
12799
+ entry.expiresAt = Date.now() + EDGE_HINTS_TTL_MS;
12800
+ },
12801
+ () => {
12802
+ if (sharedEdgeHints.get(baseUrl) === entry) {
12803
+ sharedEdgeHints.delete(baseUrl);
12804
+ }
12805
+ }
12806
+ );
12807
+ return entry.promise;
12808
+ }
12809
+ async function fetchEdgeHintAdverts(baseUrl, timeoutMs) {
12810
+ const { signal, cleanup } = createTimeoutController({ timeout: timeoutMs });
12811
+ try {
12812
+ const response = await withAbort(
12813
+ fetch(`${baseUrl}/v1/public/edge-hints`, {
12814
+ method: "GET",
12815
+ credentials: "omit",
12816
+ signal
12817
+ }),
12818
+ signal
12819
+ );
12820
+ if (!response.ok) {
12821
+ throw new Error(`edge-hints HTTP ${response.status}`);
12822
+ }
12823
+ const header = {
12824
+ direct: response.headers?.get("X-CB-Direct-Base") || null,
12825
+ realtime: response.headers?.get("X-CB-Realtime-Direct-Base") || null
12826
+ };
12827
+ let body;
12828
+ try {
12829
+ body = await withAbort(response.json(), signal);
12830
+ } catch (err) {
12831
+ if (header.direct || header.realtime) return [header];
12832
+ throw err;
12833
+ }
12834
+ return [
12835
+ header,
12836
+ { direct: body?.direct_base, realtime: body?.realtime_direct_base }
12837
+ ];
12838
+ } finally {
12839
+ cleanup();
12840
+ }
12841
+ }
12721
12842
  var HttpClient = class {
12722
12843
  constructor(config) {
12723
12844
  this.isRefreshing = false;
@@ -13523,28 +13644,18 @@ var HttpClient = class {
13523
13644
  * 먼저 보내 Cloudflare 를 두 번 왕복하고, 앱의 "로컬 개발 origin 허용" 이 꺼진 localhost 페이지에서는
13524
13645
  * 키 기반 opt-in 에 막혀 브라우저만 direct 를 못 썼다(Node 는 썼다 — platform-issue 01a15e24).
13525
13646
  * 서버는 이 경로를 키 없이 열어 두고 같은 값을 본문으로도 준다. 본문을 읽으므로 CORS
13526
- * Expose-Headers 에도 기대지 않는다. 실패는 조용히 무시한다 — direct 는 최적화일 뿐이다.
13647
+ * Expose-Headers 에도 기대지 않는다. 실패는 호출자에게 던지고 호출자(RealtimeAPI)가 조용히 넘어간다 —
13648
+ * direct 는 최적화일 뿐이다.
13649
+ *
13650
+ * 조회 자체는 같은 baseUrl 의 인스턴스끼리 함께 쓴다(6.7.0+, {@link sharedEdgeHints} 참고). 진행 중인
13651
+ * 조회가 있으면 새로 보내지 않고 기다리며, 성공한 결과는 잠시 다시 쓴다. 받아들일지는 이 인스턴스가 정한다 —
13652
+ * 이미 배웠거나(`directRealtimeBaseUrl`) direct 를 포기했으면(`directRealtimeDisabled`) 바꾸지 않는다.
13527
13653
  */
13528
13654
  async primeEdgeHints(timeoutMs) {
13529
13655
  if (this.directRealtimeBaseUrl || this.directRealtimeDisabled) return;
13530
- const { signal, cleanup } = createTimeoutController({ timeout: timeoutMs });
13531
- try {
13532
- const response = await withAbort(
13533
- fetch(`${this.config.baseUrl}/v1/public/edge-hints`, {
13534
- method: "GET",
13535
- credentials: "omit",
13536
- signal
13537
- }),
13538
- signal
13539
- );
13540
- if (!response.ok) {
13541
- throw new Error(`edge-hints HTTP ${response.status}`);
13542
- }
13543
- this.learnDirectOrigin(response);
13544
- const body = await withAbort(response.json(), signal);
13545
- this.adoptDirectBases(body?.direct_base, body?.realtime_direct_base);
13546
- } finally {
13547
- cleanup();
13656
+ const adverts = await lookupEdgeHints(this.config.baseUrl, timeoutMs);
13657
+ for (const advert of adverts) {
13658
+ this.adoptDirectBases(advert.direct, advert.realtime);
13548
13659
  }
13549
13660
  }
13550
13661
  /**
package/dist/index.mjs CHANGED
@@ -2022,6 +2022,11 @@ var AuthAPI = class {
2022
2022
  * role: 'editor',
2023
2023
  * custom_data: { level: 5 }
2024
2024
  * })
2025
+ *
2026
+ * // 역할 여러 개 (6.7.0+): role 은 주 역할(RLS auth.role), roles 는 추가 역할 목록을 **통째로 바꾼다**
2027
+ * // ([] 면 비우고, 빼면 그대로). auth.roles, hasRole(), 실시간 publisher_roles 는 role + roles 를 함께 본다.
2028
+ * await cb.auth.adminUpdateMember('member-uuid', { roles: ['performer'] })
2029
+ * // → { role: 'admin', roles: ['performer'], effective_roles: ['admin', 'performer'], ... }
2025
2030
  * ```
2026
2031
  */
2027
2032
  async adminUpdateMember(memberID, fields) {
@@ -7939,6 +7944,38 @@ var ConnectCancelledError = class extends Error {
7939
7944
  }
7940
7945
  };
7941
7946
  var MAX_HANDSHAKE_RETRIES = 2;
7947
+ var RETRY_IN_CONNECT_CODES = /* @__PURE__ */ new Set(["rate_limited", "auth_unavailable"]);
7948
+ var CHANNEL_CATEGORY_RE = /^[A-Za-z0-9_-]{1,128}$/;
7949
+ var CHANNEL_ROOM_RE = /^[A-Za-z0-9._-]{1,128}$/;
7950
+ var MAX_CHANNEL_LENGTH = 256;
7951
+ function toChannel(category, room) {
7952
+ if (room === void 0) return category;
7953
+ const show = (v) => typeof v === "string" ? JSON.stringify(v) : String(v);
7954
+ if (typeof room !== "string" || !CHANNEL_ROOM_RE.test(room)) {
7955
+ throw new Error(
7956
+ `Invalid room ${show(room)}: use 1-128 characters of letters, digits, "-", "_" or "." (the realtime channel is "<category>:<room>").`
7957
+ );
7958
+ }
7959
+ if (typeof category !== "string" || !CHANNEL_CATEGORY_RE.test(category)) {
7960
+ throw new Error(
7961
+ `Invalid category ${show(category)} for a room subscription: use the base category name (1-128 characters of letters, digits, "-" or "_") and pass the room separately with { room }.`
7962
+ );
7963
+ }
7964
+ const channel = `${category}:${room}`;
7965
+ if (channel.length > MAX_CHANNEL_LENGTH) {
7966
+ throw new Error(
7967
+ `Realtime channel "${channel}" is too long (max ${MAX_CHANNEL_LENGTH} characters including ":").`
7968
+ );
7969
+ }
7970
+ return channel;
7971
+ }
7972
+ function roomOf(channel) {
7973
+ const idx = channel.indexOf(":");
7974
+ return idx > 0 && idx < channel.length - 1 ? channel.slice(idx + 1) : void 0;
7975
+ }
7976
+ function toMemberId(value) {
7977
+ return typeof value === "string" && value !== "" ? value : void 0;
7978
+ }
7942
7979
  function jitter(max) {
7943
7980
  return Math.floor(Math.random() * Math.max(0, max));
7944
7981
  }
@@ -8159,31 +8196,46 @@ var RealtimeAPI = class {
8159
8196
  }
8160
8197
  /**
8161
8198
  * 카테고리 구독
8199
+ *
8200
+ * `options.room` 을 주면 그 카테고리의 room 하나를 구독한다 (6.7.0+). 서버 채널 이름은
8201
+ * `${category}:${room}` 이고, 구독 객체의 `send` / `getHistory` / `unsubscribe` / `onMessage`, 재연결 뒤
8202
+ * 다시 구독하기는 모두 이 채널 이름으로 동작한다(`subscription.channel`).
8203
+ *
8204
+ * @example
8205
+ * ```typescript
8206
+ * const stage = await client.realtime.subscribe('concert', { room: 'show-1025' })
8207
+ * stage.channel // 'concert:show-1025'
8208
+ * stage.room // 'show-1025'
8209
+ * stage.info.isPublisher // room 인가 함수가 publisher: true 를 돌려준 로그인 멤버면 true
8210
+ * ```
8162
8211
  */
8163
8212
  async subscribe(category, options = {}) {
8213
+ const channel = toChannel(category, options.room);
8164
8214
  this.assertBidirectional("subscribe");
8165
8215
  if (this.state !== "connected") {
8166
8216
  throw new Error("Not connected. Call connect() first.");
8167
8217
  }
8168
8218
  const requestId = this.generateRequestId();
8169
8219
  const response = await this.sendRequest({
8170
- category,
8220
+ category: channel,
8171
8221
  action: "subscribe",
8172
8222
  request_id: requestId
8173
8223
  });
8174
8224
  const info = toCategoryInfo(response);
8175
8225
  const handlers = [];
8176
- this.subscriptions.set(category, { info, handlers });
8226
+ this.subscriptions.set(channel, { info, handlers });
8177
8227
  const subscription = {
8178
8228
  info,
8229
+ channel,
8230
+ room: roomOf(channel),
8179
8231
  send: async (data, sendOptions) => {
8180
- await this.sendMessage(category, data, sendOptions);
8232
+ await this.sendMessage(channel, data, sendOptions);
8181
8233
  },
8182
8234
  getHistory: async (limit) => {
8183
- return this.getHistory(category, limit ?? options.historyLimit);
8235
+ return this.getHistory(channel, limit ?? options.historyLimit);
8184
8236
  },
8185
8237
  unsubscribe: async () => {
8186
- await this.unsubscribe(category);
8238
+ await this.unsubscribe(channel);
8187
8239
  },
8188
8240
  onMessage: (handler) => {
8189
8241
  handlers.push(handler);
@@ -8197,6 +8249,7 @@ var RealtimeAPI = class {
8197
8249
  }
8198
8250
  /**
8199
8251
  * 구독 해제
8252
+ * @param category 카테고리 이름. room 을 구독했으면 채널 이름(`category:room`, `subscription.channel`)
8200
8253
  */
8201
8254
  async unsubscribe(category) {
8202
8255
  if (this.state !== "connected") {
@@ -8212,7 +8265,7 @@ var RealtimeAPI = class {
8212
8265
  }
8213
8266
  /**
8214
8267
  * 메시지 전송
8215
- * @param category 카테고리 이름
8268
+ * @param category 카테고리 이름. room 을 구독했으면 채널 이름(`category:room`, `subscription.channel`)
8216
8269
  * @param data 전송할 데이터
8217
8270
  * @param options 전송 옵션 (includeSelf: 발신자도 메시지 수신 여부, 기본값 true)
8218
8271
  */
@@ -8233,6 +8286,7 @@ var RealtimeAPI = class {
8233
8286
  }
8234
8287
  /**
8235
8288
  * 히스토리 조회
8289
+ * @param category 카테고리 이름. room 을 구독했으면 채널 이름(`category:room`, `subscription.channel`)
8236
8290
  */
8237
8291
  async getHistory(category, limit) {
8238
8292
  this.assertBidirectional("getHistory");
@@ -8252,6 +8306,7 @@ var RealtimeAPI = class {
8252
8306
  id: m.id,
8253
8307
  category: m.category,
8254
8308
  from: m.from,
8309
+ fromMember: toMemberId(m.from_member),
8255
8310
  data: m.data,
8256
8311
  sentAt: m.sent_at
8257
8312
  })),
@@ -8941,7 +8996,9 @@ var RealtimeAPI = class {
8941
8996
  * 한국 사용자는 미국(LAX)을 거친 ~270ms 로 붙었다(platform-issue 01a15d9d 의 273ms). 응답 헤더는
8942
8997
  * HttpClient 가 평소처럼 학습한다. 광고 대상이 아니면(해외, 킬 스위치) 빈 응답이라 원래 소켓
8943
8998
  * URL 로 간다. 실패하거나 느리면(1.5초) 그냥 넘어간다 — 최적화일 뿐 연결의 전제가 아니다.
8944
- * 세션당 한 번만 묻는다.
8999
+ * 인스턴스당 한 번만 묻고, 같은 baseUrl 의 인스턴스끼리는 진행 중인 조회와 배운 결과를 함께 쓴다
9000
+ * (6.7.0+, HttpClient.primeEdgeHints 참고 — 한 페이지의 인스턴스 10개가 동시에 물어 대부분 1.5초를 넘기던
9001
+ * platform-issue 01a15e81).
8945
9002
  */
8946
9003
  async primeDirectRealtime() {
8947
9004
  if (this.directRealtimePrimed) return;
@@ -8960,12 +9017,17 @@ var RealtimeAPI = class {
8960
9017
  /**
8961
9018
  * 첫 연결을 연결 제한 시간(`timeout`) 안에서 다시 시도한다 (6.6.0+, platform-issue 01a15e24).
8962
9019
  *
8963
- * - 서버가 재시도 시각을 알려 준 거부(`retryAfterMs`, 예: 한 IP 에서 연결이 몰린 `rate_limited`):
8964
- * 그 시각에 지터를 더해 다시 붙는다. 공연 시작처럼 한꺼번에 들어올 때 앱이 재시도를 짜지 않아도 된다.
9020
+ * - 서버가 재시도 시각을 알려 준 거부(`retryAfterMs`, 예: 한 IP 에서 연결이 몰린 `rate_limited`, 인증 서버가
9021
+ * 잠깐 닿지 않는 `auth_unavailable`): 그 시각에 지터를 더해 다시 붙는다. 공연 시작처럼 한꺼번에 들어올 때
9022
+ * 앱이 재시도를 짜지 않아도 된다.
9023
+ * - `rate_limited` / `auth_unavailable` 인데 재시도 시각이 없으면(사유 프레임 없이 close 코드만 왔거나 구버전
9024
+ * 서버) 지수 백오프로 다시 한다. `auth_unavailable` 은 6.6.0 까지 바로 던져, 인증 서버가 몇 초 흔들리는
9025
+ * 동안 들어온 관객이 전부 연결에 실패했다(6.7.0, platform-issue 01a15e81).
8965
9026
  * - 이유 없는 핸드셰이크 실패: 두 번까지 백오프 후 다시 한다. 네트워크가 WebSocket 을 실제로 막으면
8966
9027
  * 매번 실패하고, 앞단의 일시 거부는 다시 하면 붙는다 — 한 번 실패로 SSE 폴백하던 것이 연결 속도
8967
9028
  * 제한을 "네트워크 차단" 으로 오진한 원인이었다.
8968
- * - 그 밖의 거부(자격증명, 동시접속 한도 등)와 시간 초과는 바로 던진다.
9029
+ * - 그 밖의 거부(자격증명, 동시접속 한도 등)와 시간 초과는 바로 던진다. 다시 시도할 시간이 남지 않아도
9030
+ * 마지막 거부를 그대로 던진다(`retryAfterMs` 가 있으면 앱이 그 시각에 다시 부를 수 있다).
8969
9031
  */
8970
9032
  async connectWithRetry() {
8971
9033
  const generation = this.connectGeneration;
@@ -8979,10 +9041,10 @@ var RealtimeAPI = class {
8979
9041
  return;
8980
9042
  } catch (e) {
8981
9043
  let wait = null;
8982
- if (e instanceof RealtimeConnectionError) {
8983
- if (e.retryable && e.retryAfterMs !== void 0) {
9044
+ if (e instanceof RealtimeConnectionError && e.retryable) {
9045
+ if (e.retryAfterMs !== void 0) {
8984
9046
  wait = e.retryAfterMs + jitter(Math.min(100 * 2 ** attempt, 3e3));
8985
- } else if (e.code === "rate_limited") {
9047
+ } else if (RETRY_IN_CONNECT_CODES.has(e.code)) {
8986
9048
  const base = Math.min(500 * 2 ** (attempt - 1), 8e3);
8987
9049
  wait = base / 2 + jitter(base / 2);
8988
9050
  }
@@ -9213,7 +9275,9 @@ var RealtimeAPI = class {
9213
9275
  const message = {
9214
9276
  id: data.id,
9215
9277
  category: data.category,
9278
+ // from 은 클라이언트가 정한 client_id 라 믿을 수 없다. 보낸 사람은 fromMember(서버 검증)로 본다.
9216
9279
  from: data.from,
9280
+ fromMember: toMemberId(data.from_member),
9217
9281
  data: data.data,
9218
9282
  sentAt: data.sent_at,
9219
9283
  fromPublisher: data.from_publisher === true
@@ -11073,8 +11137,8 @@ var SupportAPI = class {
11073
11137
  }
11074
11138
  };
11075
11139
  function detectSdkVersion() {
11076
- if (typeof __SDK_VERSION__ !== "undefined" && __SDK_VERSION__) {
11077
- return __SDK_VERSION__;
11140
+ if ("6.7.0") {
11141
+ return "6.7.0";
11078
11142
  }
11079
11143
  return "unknown";
11080
11144
  }
@@ -12666,6 +12730,63 @@ function isConsolePlatformToken(token) {
12666
12730
  const role = decodeJwtPayload(token)?.role;
12667
12731
  return role === "User" || role === "AdminInvite";
12668
12732
  }
12733
+ var EDGE_HINTS_TTL_MS = 10 * 6e4;
12734
+ var sharedEdgeHints = /* @__PURE__ */ new Map();
12735
+ function lookupEdgeHints(baseUrl, timeoutMs) {
12736
+ const cached = sharedEdgeHints.get(baseUrl);
12737
+ if (cached && Date.now() < cached.expiresAt) return cached.promise;
12738
+ const entry = {
12739
+ promise: fetchEdgeHintAdverts(baseUrl, timeoutMs),
12740
+ // 진행 중에는 만료되지 않는다 — 시간 제한(withAbort) 때문에 timeoutMs 안에 반드시 끝난다. 늦게 합류한
12741
+ // 인스턴스는 남은 시간만 기다린다.
12742
+ expiresAt: Number.POSITIVE_INFINITY
12743
+ };
12744
+ sharedEdgeHints.set(baseUrl, entry);
12745
+ entry.promise.then(
12746
+ () => {
12747
+ entry.expiresAt = Date.now() + EDGE_HINTS_TTL_MS;
12748
+ },
12749
+ () => {
12750
+ if (sharedEdgeHints.get(baseUrl) === entry) {
12751
+ sharedEdgeHints.delete(baseUrl);
12752
+ }
12753
+ }
12754
+ );
12755
+ return entry.promise;
12756
+ }
12757
+ async function fetchEdgeHintAdverts(baseUrl, timeoutMs) {
12758
+ const { signal, cleanup } = createTimeoutController({ timeout: timeoutMs });
12759
+ try {
12760
+ const response = await withAbort(
12761
+ fetch(`${baseUrl}/v1/public/edge-hints`, {
12762
+ method: "GET",
12763
+ credentials: "omit",
12764
+ signal
12765
+ }),
12766
+ signal
12767
+ );
12768
+ if (!response.ok) {
12769
+ throw new Error(`edge-hints HTTP ${response.status}`);
12770
+ }
12771
+ const header = {
12772
+ direct: response.headers?.get("X-CB-Direct-Base") || null,
12773
+ realtime: response.headers?.get("X-CB-Realtime-Direct-Base") || null
12774
+ };
12775
+ let body;
12776
+ try {
12777
+ body = await withAbort(response.json(), signal);
12778
+ } catch (err) {
12779
+ if (header.direct || header.realtime) return [header];
12780
+ throw err;
12781
+ }
12782
+ return [
12783
+ header,
12784
+ { direct: body?.direct_base, realtime: body?.realtime_direct_base }
12785
+ ];
12786
+ } finally {
12787
+ cleanup();
12788
+ }
12789
+ }
12669
12790
  var HttpClient = class {
12670
12791
  constructor(config) {
12671
12792
  this.isRefreshing = false;
@@ -13471,28 +13592,18 @@ var HttpClient = class {
13471
13592
  * 먼저 보내 Cloudflare 를 두 번 왕복하고, 앱의 "로컬 개발 origin 허용" 이 꺼진 localhost 페이지에서는
13472
13593
  * 키 기반 opt-in 에 막혀 브라우저만 direct 를 못 썼다(Node 는 썼다 — platform-issue 01a15e24).
13473
13594
  * 서버는 이 경로를 키 없이 열어 두고 같은 값을 본문으로도 준다. 본문을 읽으므로 CORS
13474
- * Expose-Headers 에도 기대지 않는다. 실패는 조용히 무시한다 — direct 는 최적화일 뿐이다.
13595
+ * Expose-Headers 에도 기대지 않는다. 실패는 호출자에게 던지고 호출자(RealtimeAPI)가 조용히 넘어간다 —
13596
+ * direct 는 최적화일 뿐이다.
13597
+ *
13598
+ * 조회 자체는 같은 baseUrl 의 인스턴스끼리 함께 쓴다(6.7.0+, {@link sharedEdgeHints} 참고). 진행 중인
13599
+ * 조회가 있으면 새로 보내지 않고 기다리며, 성공한 결과는 잠시 다시 쓴다. 받아들일지는 이 인스턴스가 정한다 —
13600
+ * 이미 배웠거나(`directRealtimeBaseUrl`) direct 를 포기했으면(`directRealtimeDisabled`) 바꾸지 않는다.
13475
13601
  */
13476
13602
  async primeEdgeHints(timeoutMs) {
13477
13603
  if (this.directRealtimeBaseUrl || this.directRealtimeDisabled) return;
13478
- const { signal, cleanup } = createTimeoutController({ timeout: timeoutMs });
13479
- try {
13480
- const response = await withAbort(
13481
- fetch(`${this.config.baseUrl}/v1/public/edge-hints`, {
13482
- method: "GET",
13483
- credentials: "omit",
13484
- signal
13485
- }),
13486
- signal
13487
- );
13488
- if (!response.ok) {
13489
- throw new Error(`edge-hints HTTP ${response.status}`);
13490
- }
13491
- this.learnDirectOrigin(response);
13492
- const body = await withAbort(response.json(), signal);
13493
- this.adoptDirectBases(body?.direct_base, body?.realtime_direct_base);
13494
- } finally {
13495
- cleanup();
13604
+ const adverts = await lookupEdgeHints(this.config.baseUrl, timeoutMs);
13605
+ for (const advert of adverts) {
13606
+ this.adoptDirectBases(advert.direct, advert.realtime);
13496
13607
  }
13497
13608
  }
13498
13609
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "connectbase-client",
3
- "version": "6.6.0",
3
+ "version": "6.7.0",
4
4
  "description": "Connect Base JavaScript/TypeScript SDK for browser and Node.js",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,18 +18,24 @@
18
18
  "main": "dist/index.js",
19
19
  "module": "dist/index.mjs",
20
20
  "types": "dist/index.d.ts",
21
- "browser": "dist/connect-base.umd.js",
22
21
  "unpkg": "dist/connect-base.umd.js",
22
+ "jsdelivr": "dist/connect-base.umd.js",
23
23
  "bin": {
24
24
  "connectbase": "dist/cli.js",
25
25
  "connectbase-client": "dist/cli.js"
26
26
  },
27
27
  "exports": {
28
28
  ".": {
29
- "types": "./dist/index.d.ts",
30
- "import": "./dist/index.mjs",
31
- "require": "./dist/index.js"
32
- }
29
+ "import": {
30
+ "types": "./dist/index.d.mts",
31
+ "default": "./dist/index.mjs"
32
+ },
33
+ "require": {
34
+ "types": "./dist/index.d.ts",
35
+ "default": "./dist/index.js"
36
+ }
37
+ },
38
+ "./package.json": "./package.json"
33
39
  },
34
40
  "files": [
35
41
  "dist",
@@ -73,6 +79,6 @@
73
79
  "vitest": "^3.2.4"
74
80
  },
75
81
  "engines": {
76
- "node": ">=16"
82
+ "node": ">=20"
77
83
  }
78
84
  }