connectbase-client 3.32.0 → 3.34.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 +41 -0
- package/README.md +11 -3
- package/dist/connect-base.umd.js +5 -4
- package/dist/index.d.mts +69 -9
- package/dist/index.d.ts +69 -9
- package/dist/index.js +230 -7
- package/dist/index.mjs +230 -7
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2959,13 +2959,25 @@ var RealtimeAPI = class {
|
|
|
2959
2959
|
this.state = "disconnected";
|
|
2960
2960
|
this._connectionId = null;
|
|
2961
2961
|
this._appId = null;
|
|
2962
|
+
/**
|
|
2963
|
+
* 현재 활성 transport.
|
|
2964
|
+
* - `'ws'`: WebSocket 정상 연결
|
|
2965
|
+
* - `'sse'`: WS 핸드셰이크 실패로 HTTP SSE fallback 모드 (AI `stream()` 만 지원)
|
|
2966
|
+
* - `null`: 미연결
|
|
2967
|
+
*/
|
|
2968
|
+
this.activeTransport = null;
|
|
2969
|
+
/** SSE fallback 모드 활성 여부. true 면 백그라운드 WS 재연결 머신을 정지한다. */
|
|
2970
|
+
this.sseFallbackActive = false;
|
|
2971
|
+
/** SSE fallback 스트림의 AbortController (sessionId → controller) — stop()/disconnect() 에서 중단용 */
|
|
2972
|
+
this.sseSessions = /* @__PURE__ */ new Map();
|
|
2962
2973
|
this.options = {
|
|
2963
2974
|
maxRetries: 5,
|
|
2964
2975
|
retryInterval: 1e3,
|
|
2965
2976
|
userId: "",
|
|
2966
2977
|
accessToken: "",
|
|
2967
2978
|
timeout: 3e4,
|
|
2968
|
-
debug: false
|
|
2979
|
+
debug: false,
|
|
2980
|
+
fallback: "sse"
|
|
2969
2981
|
};
|
|
2970
2982
|
this.retryCount = 0;
|
|
2971
2983
|
this.pendingRequests = /* @__PURE__ */ new Map();
|
|
@@ -2994,6 +3006,13 @@ var RealtimeAPI = class {
|
|
|
2994
3006
|
get appId() {
|
|
2995
3007
|
return this._appId;
|
|
2996
3008
|
}
|
|
3009
|
+
/**
|
|
3010
|
+
* 현재 활성 transport (`'ws'` | `'sse'` | `null`).
|
|
3011
|
+
* `'sse'` 이면 WS 가 차단되어 fallback 모드로 동작 중이며 AI `stream()` 만 가능하다.
|
|
3012
|
+
*/
|
|
3013
|
+
get transport() {
|
|
3014
|
+
return this.activeTransport;
|
|
3015
|
+
}
|
|
2997
3016
|
/**
|
|
2998
3017
|
* WebSocket 연결
|
|
2999
3018
|
* @param options 연결 옵션
|
|
@@ -3014,10 +3033,36 @@ var RealtimeAPI = class {
|
|
|
3014
3033
|
this.connectPromise = this.doConnect();
|
|
3015
3034
|
try {
|
|
3016
3035
|
await this.connectPromise;
|
|
3036
|
+
} catch (e) {
|
|
3037
|
+
const hasAuth = !!(this.options.accessToken || this.http.getPublicKey());
|
|
3038
|
+
if (this.options.fallback !== "none" && hasAuth) {
|
|
3039
|
+
this.activateSseFallback(e);
|
|
3040
|
+
} else {
|
|
3041
|
+
throw e;
|
|
3042
|
+
}
|
|
3017
3043
|
} finally {
|
|
3018
3044
|
this.connectPromise = null;
|
|
3019
3045
|
}
|
|
3020
3046
|
}
|
|
3047
|
+
/**
|
|
3048
|
+
* SSE fallback 모드로 전환한다. WS 는 사용 불가하지만 state 는 'connected' 로 두어
|
|
3049
|
+
* (degraded) AI stream() 이 SSE 로 동작하게 한다. 백그라운드 WS 재연결 머신은 정지한다.
|
|
3050
|
+
*/
|
|
3051
|
+
activateSseFallback(cause) {
|
|
3052
|
+
if (this.ws) {
|
|
3053
|
+
try {
|
|
3054
|
+
this.ws.close();
|
|
3055
|
+
} catch {
|
|
3056
|
+
}
|
|
3057
|
+
this.ws = null;
|
|
3058
|
+
}
|
|
3059
|
+
this.sseFallbackActive = true;
|
|
3060
|
+
this.activeTransport = "sse";
|
|
3061
|
+
this.state = "connected";
|
|
3062
|
+
this.retryCount = 0;
|
|
3063
|
+
this.log(`WS unavailable (${cause instanceof Error ? cause.message : String(cause)}); using SSE fallback transport for AI streaming`);
|
|
3064
|
+
this.notifyStateChange();
|
|
3065
|
+
}
|
|
3021
3066
|
/**
|
|
3022
3067
|
* 연결 해제
|
|
3023
3068
|
*/
|
|
@@ -3040,6 +3085,15 @@ var RealtimeAPI = class {
|
|
|
3040
3085
|
}
|
|
3041
3086
|
});
|
|
3042
3087
|
this.streamSessions.clear();
|
|
3088
|
+
this.sseSessions.forEach((controller) => {
|
|
3089
|
+
try {
|
|
3090
|
+
controller.abort();
|
|
3091
|
+
} catch {
|
|
3092
|
+
}
|
|
3093
|
+
});
|
|
3094
|
+
this.sseSessions.clear();
|
|
3095
|
+
this.sseFallbackActive = false;
|
|
3096
|
+
this.activeTransport = null;
|
|
3043
3097
|
this.presenceHandlers = [];
|
|
3044
3098
|
this.presenceSubscriptions.clear();
|
|
3045
3099
|
this.typingHandlers.clear();
|
|
@@ -3053,6 +3107,7 @@ var RealtimeAPI = class {
|
|
|
3053
3107
|
* 카테고리 구독
|
|
3054
3108
|
*/
|
|
3055
3109
|
async subscribe(category, options = {}) {
|
|
3110
|
+
this.assertBidirectional("subscribe");
|
|
3056
3111
|
if (this.state !== "connected") {
|
|
3057
3112
|
throw new Error("Not connected. Call connect() first.");
|
|
3058
3113
|
}
|
|
@@ -3113,6 +3168,7 @@ var RealtimeAPI = class {
|
|
|
3113
3168
|
* @param options 전송 옵션 (includeSelf: 발신자도 메시지 수신 여부, 기본값 true)
|
|
3114
3169
|
*/
|
|
3115
3170
|
async sendMessage(category, data, options = {}) {
|
|
3171
|
+
this.assertBidirectional("sendMessage");
|
|
3116
3172
|
if (this.state !== "connected") {
|
|
3117
3173
|
throw new Error("Not connected");
|
|
3118
3174
|
}
|
|
@@ -3130,6 +3186,7 @@ var RealtimeAPI = class {
|
|
|
3130
3186
|
* 히스토리 조회
|
|
3131
3187
|
*/
|
|
3132
3188
|
async getHistory(category, limit) {
|
|
3189
|
+
this.assertBidirectional("getHistory");
|
|
3133
3190
|
if (this.state !== "connected") {
|
|
3134
3191
|
throw new Error("Not connected");
|
|
3135
3192
|
}
|
|
@@ -3185,9 +3242,22 @@ var RealtimeAPI = class {
|
|
|
3185
3242
|
* ```
|
|
3186
3243
|
*/
|
|
3187
3244
|
async stream(messages, handlers, options = {}) {
|
|
3188
|
-
|
|
3189
|
-
|
|
3245
|
+
const canWs = this.state === "connected" && this.activeTransport === "ws" && this.ws?.readyState === WebSocket.OPEN;
|
|
3246
|
+
if (canWs) {
|
|
3247
|
+
try {
|
|
3248
|
+
return this.streamViaWS(messages, handlers, options);
|
|
3249
|
+
} catch (e) {
|
|
3250
|
+
if (this.options.fallback === "none") throw e;
|
|
3251
|
+
this.log(`WS stream send failed (${e instanceof Error ? e.message : String(e)}); falling back to SSE`);
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
if (this.options.fallback !== "none") {
|
|
3255
|
+
return this.streamViaSSE(messages, handlers, options);
|
|
3190
3256
|
}
|
|
3257
|
+
throw new Error("Not connected. Call connect() first.");
|
|
3258
|
+
}
|
|
3259
|
+
/** WebSocket 으로 AI 스트리밍 (기본 경로) */
|
|
3260
|
+
streamViaWS(messages, handlers, options) {
|
|
3191
3261
|
const requestId = this.generateRequestId();
|
|
3192
3262
|
const sessionId = options.sessionId || this.generateRequestId();
|
|
3193
3263
|
this.streamSessions.set(sessionId, {
|
|
@@ -3222,11 +3292,130 @@ var RealtimeAPI = class {
|
|
|
3222
3292
|
}
|
|
3223
3293
|
};
|
|
3224
3294
|
}
|
|
3295
|
+
/**
|
|
3296
|
+
* HTTP SSE (`/v1/public/ai/chat/stream`) 로 AI 스트리밍 — WS 가 차단된 네트워크용 fallback.
|
|
3297
|
+
*
|
|
3298
|
+
* SSE 는 단방향이라 WS 의 `StreamDoneData` 토큰 카운트(total/prompt)를 받지 못하므로
|
|
3299
|
+
* `totalTokens`/`promptTokens` 는 0 으로 채워진다(`fullText`/`duration` 은 정확). `mcpGroup`
|
|
3300
|
+
* (MCP 도구) 은 SSE fallback 에서 미지원 — 지정 시 무시되고 도구 없이 스트리밍한다.
|
|
3301
|
+
*/
|
|
3302
|
+
streamViaSSE(messages, handlers, options) {
|
|
3303
|
+
const sessionId = options.sessionId || this.generateRequestId();
|
|
3304
|
+
const controller = new AbortController();
|
|
3305
|
+
this.sseSessions.set(sessionId, controller);
|
|
3306
|
+
if (options.mcpGroup) {
|
|
3307
|
+
this.log("mcpGroup is not supported in SSE fallback mode; streaming without MCP tools");
|
|
3308
|
+
}
|
|
3309
|
+
this.log(`Streaming via SSE fallback (session ${sessionId})`);
|
|
3310
|
+
void this.runSseStream(sessionId, messages, handlers, options, controller.signal);
|
|
3311
|
+
return {
|
|
3312
|
+
sessionId,
|
|
3313
|
+
stop: async () => {
|
|
3314
|
+
await this.stopStream(sessionId);
|
|
3315
|
+
}
|
|
3316
|
+
};
|
|
3317
|
+
}
|
|
3318
|
+
async runSseStream(sessionId, messages, handlers, options, signal) {
|
|
3319
|
+
const startedAt = Date.now();
|
|
3320
|
+
let tokenIndex = 0;
|
|
3321
|
+
let fullText = "";
|
|
3322
|
+
let reader;
|
|
3323
|
+
const wireMessages = options.system ? [{ role: "system", content: options.system }, ...messages] : messages;
|
|
3324
|
+
const body = {
|
|
3325
|
+
messages: wireMessages,
|
|
3326
|
+
provider: options.provider,
|
|
3327
|
+
model: options.model,
|
|
3328
|
+
temperature: options.temperature,
|
|
3329
|
+
maxTokens: options.maxTokens
|
|
3330
|
+
};
|
|
3331
|
+
try {
|
|
3332
|
+
const response = await this.http.fetchRaw("/v1/public/ai/chat/stream", {
|
|
3333
|
+
method: "POST",
|
|
3334
|
+
headers: { "Content-Type": "application/json" },
|
|
3335
|
+
body: JSON.stringify(body),
|
|
3336
|
+
signal
|
|
3337
|
+
});
|
|
3338
|
+
if (!response.ok) {
|
|
3339
|
+
const errData = await response.json().catch(() => ({ error: "Stream request failed" }));
|
|
3340
|
+
handlers.onError?.(new Error(errData.error || errData.message || "Stream request failed"));
|
|
3341
|
+
return;
|
|
3342
|
+
}
|
|
3343
|
+
reader = response.body?.getReader();
|
|
3344
|
+
if (!reader) {
|
|
3345
|
+
handlers.onError?.(new Error("ReadableStream not supported"));
|
|
3346
|
+
return;
|
|
3347
|
+
}
|
|
3348
|
+
const decoder = new TextDecoder();
|
|
3349
|
+
let buffer = "";
|
|
3350
|
+
while (true) {
|
|
3351
|
+
const { done, value } = await reader.read();
|
|
3352
|
+
if (done) break;
|
|
3353
|
+
buffer += decoder.decode(value, { stream: true });
|
|
3354
|
+
const lines = buffer.split("\n");
|
|
3355
|
+
buffer = lines.pop() || "";
|
|
3356
|
+
for (const line of lines) {
|
|
3357
|
+
if (!line.startsWith("data: ")) continue;
|
|
3358
|
+
const data = line.slice(6).trim();
|
|
3359
|
+
if (data === "[DONE]") {
|
|
3360
|
+
handlers.onDone?.({ sessionId, fullText, totalTokens: 0, promptTokens: 0, duration: Date.now() - startedAt });
|
|
3361
|
+
return;
|
|
3362
|
+
}
|
|
3363
|
+
try {
|
|
3364
|
+
const ev = JSON.parse(data);
|
|
3365
|
+
if (ev.error) {
|
|
3366
|
+
handlers.onError?.(new Error(ev.message || ev.error || "stream error"));
|
|
3367
|
+
return;
|
|
3368
|
+
}
|
|
3369
|
+
if (ev.type === "tool_start") {
|
|
3370
|
+
handlers.onToolCall?.(ev.name || "", ev.arguments || {}, tokenIndex);
|
|
3371
|
+
continue;
|
|
3372
|
+
}
|
|
3373
|
+
if (ev.type === "tool_end") {
|
|
3374
|
+
handlers.onToolResult?.(ev.name || "", ev.success !== false, ev.durationMs || 0, tokenIndex);
|
|
3375
|
+
continue;
|
|
3376
|
+
}
|
|
3377
|
+
if (ev.type === "sources" || ev.type === "searching" || ev.type === "heartbeat") {
|
|
3378
|
+
continue;
|
|
3379
|
+
}
|
|
3380
|
+
if (ev.content) {
|
|
3381
|
+
fullText += ev.content;
|
|
3382
|
+
handlers.onToken?.(ev.content, tokenIndex++);
|
|
3383
|
+
}
|
|
3384
|
+
if (ev.done) {
|
|
3385
|
+
handlers.onDone?.({ sessionId, fullText, totalTokens: 0, promptTokens: 0, duration: Date.now() - startedAt });
|
|
3386
|
+
return;
|
|
3387
|
+
}
|
|
3388
|
+
} catch {
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
} catch (err) {
|
|
3393
|
+
const aborted = signal.aborted || err instanceof DOMException && err.name === "AbortError" || typeof err === "object" && err !== null && err.name === "AbortError";
|
|
3394
|
+
if (!aborted) {
|
|
3395
|
+
handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
3396
|
+
}
|
|
3397
|
+
} finally {
|
|
3398
|
+
this.sseSessions.delete(sessionId);
|
|
3399
|
+
if (reader) {
|
|
3400
|
+
try {
|
|
3401
|
+
await reader.cancel();
|
|
3402
|
+
} catch {
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3225
3407
|
/**
|
|
3226
3408
|
* 스트리밍 중지
|
|
3227
3409
|
*/
|
|
3228
3410
|
async stopStream(sessionId) {
|
|
3229
|
-
|
|
3411
|
+
const sse = this.sseSessions.get(sessionId);
|
|
3412
|
+
if (sse) {
|
|
3413
|
+
sse.abort();
|
|
3414
|
+
this.sseSessions.delete(sessionId);
|
|
3415
|
+
return;
|
|
3416
|
+
}
|
|
3417
|
+
if (this.state !== "connected" || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
3418
|
+
this.streamSessions.delete(sessionId);
|
|
3230
3419
|
return;
|
|
3231
3420
|
}
|
|
3232
3421
|
const requestId = this.generateRequestId();
|
|
@@ -3285,6 +3474,7 @@ var RealtimeAPI = class {
|
|
|
3285
3474
|
* ```
|
|
3286
3475
|
*/
|
|
3287
3476
|
async setPresence(status, options = {}) {
|
|
3477
|
+
this.assertBidirectional("setPresence");
|
|
3288
3478
|
if (this.state !== "connected") {
|
|
3289
3479
|
throw new Error("Not connected");
|
|
3290
3480
|
}
|
|
@@ -3312,6 +3502,7 @@ var RealtimeAPI = class {
|
|
|
3312
3502
|
* ```
|
|
3313
3503
|
*/
|
|
3314
3504
|
async setPresenceOnDisconnect(status, metadata) {
|
|
3505
|
+
this.assertBidirectional("setPresenceOnDisconnect");
|
|
3315
3506
|
if (this.state !== "connected") {
|
|
3316
3507
|
throw new Error("Not connected");
|
|
3317
3508
|
}
|
|
@@ -3335,6 +3526,7 @@ var RealtimeAPI = class {
|
|
|
3335
3526
|
* ```
|
|
3336
3527
|
*/
|
|
3337
3528
|
async getPresence(userId) {
|
|
3529
|
+
this.assertBidirectional("getPresence");
|
|
3338
3530
|
if (this.state !== "connected") {
|
|
3339
3531
|
throw new Error("Not connected");
|
|
3340
3532
|
}
|
|
@@ -3365,6 +3557,7 @@ var RealtimeAPI = class {
|
|
|
3365
3557
|
* ```
|
|
3366
3558
|
*/
|
|
3367
3559
|
async getPresenceMany(userIds) {
|
|
3560
|
+
this.assertBidirectional("getPresenceMany");
|
|
3368
3561
|
if (this.state !== "connected") {
|
|
3369
3562
|
throw new Error("Not connected");
|
|
3370
3563
|
}
|
|
@@ -3404,6 +3597,7 @@ var RealtimeAPI = class {
|
|
|
3404
3597
|
* ```
|
|
3405
3598
|
*/
|
|
3406
3599
|
async subscribePresence(userId, handler) {
|
|
3600
|
+
this.assertBidirectional("subscribePresence");
|
|
3407
3601
|
if (this.state !== "connected") {
|
|
3408
3602
|
throw new Error("Not connected");
|
|
3409
3603
|
}
|
|
@@ -3473,6 +3667,7 @@ var RealtimeAPI = class {
|
|
|
3473
3667
|
* ```
|
|
3474
3668
|
*/
|
|
3475
3669
|
async startTyping(roomId) {
|
|
3670
|
+
this.assertBidirectional("startTyping");
|
|
3476
3671
|
if (this.state !== "connected") {
|
|
3477
3672
|
throw new Error("Not connected");
|
|
3478
3673
|
}
|
|
@@ -3497,6 +3692,7 @@ var RealtimeAPI = class {
|
|
|
3497
3692
|
* ```
|
|
3498
3693
|
*/
|
|
3499
3694
|
async stopTyping(roomId) {
|
|
3695
|
+
this.assertBidirectional("stopTyping");
|
|
3500
3696
|
if (this.state !== "connected") {
|
|
3501
3697
|
throw new Error("Not connected");
|
|
3502
3698
|
}
|
|
@@ -3526,6 +3722,7 @@ var RealtimeAPI = class {
|
|
|
3526
3722
|
* ```
|
|
3527
3723
|
*/
|
|
3528
3724
|
async onTypingChange(roomId, handler) {
|
|
3725
|
+
this.assertBidirectional("onTypingChange");
|
|
3529
3726
|
if (this.state !== "connected") {
|
|
3530
3727
|
throw new Error("Not connected");
|
|
3531
3728
|
}
|
|
@@ -3573,6 +3770,7 @@ var RealtimeAPI = class {
|
|
|
3573
3770
|
* ```
|
|
3574
3771
|
*/
|
|
3575
3772
|
async markRead(category, messageIds) {
|
|
3773
|
+
this.assertBidirectional("markRead");
|
|
3576
3774
|
if (this.state !== "connected") {
|
|
3577
3775
|
throw new Error("Not connected");
|
|
3578
3776
|
}
|
|
@@ -3721,6 +3919,8 @@ var RealtimeAPI = class {
|
|
|
3721
3919
|
this._connectionId = data.connection_id;
|
|
3722
3920
|
this._appId = data.app_id;
|
|
3723
3921
|
this.state = "connected";
|
|
3922
|
+
this.activeTransport = "ws";
|
|
3923
|
+
this.sseFallbackActive = false;
|
|
3724
3924
|
this.retryCount = 0;
|
|
3725
3925
|
this.notifyStateChange();
|
|
3726
3926
|
if (connectResolve) connectResolve();
|
|
@@ -3883,6 +4083,10 @@ var RealtimeAPI = class {
|
|
|
3883
4083
|
}
|
|
3884
4084
|
}
|
|
3885
4085
|
handleDisconnect() {
|
|
4086
|
+
if (this.sseFallbackActive) {
|
|
4087
|
+
this.ws = null;
|
|
4088
|
+
return;
|
|
4089
|
+
}
|
|
3886
4090
|
this.ws = null;
|
|
3887
4091
|
this._connectionId = null;
|
|
3888
4092
|
this.streamSessions.forEach((session) => {
|
|
@@ -3915,6 +4119,7 @@ var RealtimeAPI = class {
|
|
|
3915
4119
|
3e4
|
|
3916
4120
|
);
|
|
3917
4121
|
setTimeout(async () => {
|
|
4122
|
+
if (this.sseFallbackActive) return;
|
|
3918
4123
|
try {
|
|
3919
4124
|
await this.doConnect();
|
|
3920
4125
|
this.log("Reconnected successfully, restoring subscriptions...");
|
|
@@ -3925,12 +4130,19 @@ var RealtimeAPI = class {
|
|
|
3925
4130
|
);
|
|
3926
4131
|
} catch (e) {
|
|
3927
4132
|
this.logError("Reconnect failed", e);
|
|
4133
|
+
if (this.retryCount >= this.options.maxRetries && this.options.fallback !== "none" && !this.sseFallbackActive) {
|
|
4134
|
+
this.activateSseFallback(e);
|
|
4135
|
+
}
|
|
3928
4136
|
}
|
|
3929
4137
|
}, delay);
|
|
3930
4138
|
} else {
|
|
3931
|
-
this.
|
|
3932
|
-
|
|
3933
|
-
|
|
4139
|
+
if (this.options.fallback !== "none") {
|
|
4140
|
+
this.activateSseFallback(new Error("Connection lost. Max WS retries exceeded."));
|
|
4141
|
+
} else {
|
|
4142
|
+
this.state = "disconnected";
|
|
4143
|
+
this.notifyStateChange();
|
|
4144
|
+
this.notifyError(new Error("Connection lost. Max retries exceeded."));
|
|
4145
|
+
}
|
|
3934
4146
|
}
|
|
3935
4147
|
}
|
|
3936
4148
|
/**
|
|
@@ -3993,6 +4205,17 @@ var RealtimeAPI = class {
|
|
|
3993
4205
|
}
|
|
3994
4206
|
}
|
|
3995
4207
|
}
|
|
4208
|
+
/**
|
|
4209
|
+
* 양방향(WS 전용) 기능을 SSE fallback 모드에서 호출하면 명확한 에러를 던진다.
|
|
4210
|
+
* SSE 는 단방향이라 presence/typing/subscribe/sendMessage 를 지원하지 못한다.
|
|
4211
|
+
*/
|
|
4212
|
+
assertBidirectional(feature) {
|
|
4213
|
+
if (this.activeTransport === "sse") {
|
|
4214
|
+
throw new Error(
|
|
4215
|
+
`realtime.${feature}() requires a WebSocket connection, which is blocked on this network. Only AI streaming (realtime.stream) works in SSE fallback mode (cb.realtime.transport === 'sse').`
|
|
4216
|
+
);
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
3996
4219
|
sendRequest(message) {
|
|
3997
4220
|
return new Promise((resolve, reject) => {
|
|
3998
4221
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|