connectbase-client 6.6.1 → 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/CHANGELOG.md +81 -0
- package/README.md +46 -3
- package/dist/cli.js +1 -1
- package/dist/connect-base.umd.js +5 -5
- package/dist/index.d.mts +95 -15
- package/dist/index.d.ts +95 -15
- package/dist/index.js +145 -34
- package/dist/index.mjs +145 -34
- package/package.json +1 -1
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(
|
|
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(
|
|
8284
|
+
await this.sendMessage(channel, data, sendOptions);
|
|
8233
8285
|
},
|
|
8234
8286
|
getHistory: async (limit) => {
|
|
8235
|
-
return this.getHistory(
|
|
8287
|
+
return this.getHistory(channel, limit ?? options.historyLimit);
|
|
8236
8288
|
},
|
|
8237
8289
|
unsubscribe: async () => {
|
|
8238
|
-
await this.unsubscribe(
|
|
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.
|
|
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
|
|
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 (
|
|
11129
|
-
return
|
|
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 에도 기대지 않는다. 실패는
|
|
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
|
|
13531
|
-
|
|
13532
|
-
|
|
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(
|
|
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(
|
|
8232
|
+
await this.sendMessage(channel, data, sendOptions);
|
|
8181
8233
|
},
|
|
8182
8234
|
getHistory: async (limit) => {
|
|
8183
|
-
return this.getHistory(
|
|
8235
|
+
return this.getHistory(channel, limit ?? options.historyLimit);
|
|
8184
8236
|
},
|
|
8185
8237
|
unsubscribe: async () => {
|
|
8186
|
-
await this.unsubscribe(
|
|
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.
|
|
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
|
|
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 (
|
|
11077
|
-
return
|
|
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 에도 기대지 않는다. 실패는
|
|
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
|
|
13479
|
-
|
|
13480
|
-
|
|
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
|
/**
|