@vibexnpm/talkx 8.5.0 → 8.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.d.ts +62 -13
- package/dist/talkflow-sdk.esm.js +1 -1
- package/dist/talkflow-sdk.esm.js.map +1 -1
- package/dist/talkflow-sdk.standalone.js +1 -1
- package/dist/talkflow-sdk.standalone.js.map +1 -1
- package/dist/talkflow-sdk.umd.js +1 -1
- package/dist/talkflow-sdk.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/TalkFlowClient.js +21 -9
- package/src/auth/AuthSession.js +27 -2
- package/src/chat/ChatClient.js +54 -19
- package/src/constants.js +1 -0
- package/src/core/ConnectionManager.js +166 -63
- package/src/index.js +1 -0
- package/src/talkflow/eventForwarding.js +1 -0
- package/src/talkflow/session.js +3 -0
- package/src/webrtc/PeerConnectionManager.js +10 -1
- package/src/webrtc/WebRTCClient.js +287 -28
- package/types/__typetests__/authSession.types.ts +23 -1
- package/types/index.d.ts +62 -13
|
@@ -37,7 +37,8 @@ class ConnectionManager extends EventEmitter {
|
|
|
37
37
|
* @param {string} options.projectId - 프로젝트 ID
|
|
38
38
|
* @param {boolean} [options.useSockJS=true] - SockJS 사용 여부
|
|
39
39
|
* @param {number} [options.reconnectDelay] - 재연결 지연 시간
|
|
40
|
-
* @param {number} [options.maxReconnectAttempts] - 최대 재연결 시도 횟수
|
|
40
|
+
* @param {number} [options.maxReconnectAttempts] - 한 묶음의 최대 재연결 시도 횟수
|
|
41
|
+
* @param {number} [options.reconnectRecoveryDelay=60000] - 기존 연결의 재시도 소진 후 대기(ms), 0이면 비활성화
|
|
41
42
|
* @param {number} [options.heartbeatIncoming] - 수신 하트비트 간격
|
|
42
43
|
* @param {number} [options.heartbeatOutgoing] - 발신 하트비트 간격
|
|
43
44
|
* @param {number} [options.logLevel] - 로그 레벨
|
|
@@ -54,6 +55,7 @@ class ConnectionManager extends EventEmitter {
|
|
|
54
55
|
this.reconnectDelay = options.reconnectDelay || DefaultConfig.reconnectDelay;
|
|
55
56
|
this.maxReconnectDelay = options.maxReconnectDelay || DefaultConfig.maxReconnectDelay;
|
|
56
57
|
this.maxReconnectAttempts = options.maxReconnectAttempts || DefaultConfig.maxReconnectAttempts;
|
|
58
|
+
this.reconnectRecoveryDelay = options.reconnectRecoveryDelay ?? DefaultConfig.reconnectRecoveryDelay;
|
|
57
59
|
this.heartbeatIncoming = options.heartbeatIncoming || DefaultConfig.heartbeatIncoming;
|
|
58
60
|
this.heartbeatOutgoing = options.heartbeatOutgoing || DefaultConfig.heartbeatOutgoing;
|
|
59
61
|
this.resumeOnVisibility = options.resumeOnVisibility !== false;
|
|
@@ -77,6 +79,9 @@ class ConnectionManager extends EventEmitter {
|
|
|
77
79
|
// forceReconnect 단일 실행 보장 (복귀 이벤트 3종이 거의 동시에 들어온다).
|
|
78
80
|
this._forceReconnectPromise = null;
|
|
79
81
|
this._lifecycleHandlers = null;
|
|
82
|
+
this._recoveryTimer = null;
|
|
83
|
+
this._terminalAuthError = false;
|
|
84
|
+
this._recoveryEligible = false;
|
|
80
85
|
|
|
81
86
|
this._installLifecycleListeners();
|
|
82
87
|
}
|
|
@@ -148,7 +153,7 @@ class ConnectionManager extends EventEmitter {
|
|
|
148
153
|
* @private
|
|
149
154
|
*/
|
|
150
155
|
_onResume(reason) {
|
|
151
|
-
if (!this.resumeOnVisibility || this._intentionalDisconnect) {
|
|
156
|
+
if (!this.resumeOnVisibility || this._intentionalDisconnect || this._terminalAuthError) {
|
|
152
157
|
return;
|
|
153
158
|
}
|
|
154
159
|
|
|
@@ -209,26 +214,26 @@ class ConnectionManager extends EventEmitter {
|
|
|
209
214
|
* @returns {Promise<void>}
|
|
210
215
|
*/
|
|
211
216
|
async forceReconnect(reason) {
|
|
212
|
-
if (this._intentionalDisconnect || this.state === ConnectionState.CONNECTING) {
|
|
217
|
+
if (this._intentionalDisconnect || this._terminalAuthError || this.state === ConnectionState.CONNECTING) {
|
|
213
218
|
return;
|
|
214
219
|
}
|
|
215
220
|
|
|
216
221
|
// 단일 실행 — 복귀 시 visibilitychange / pageshow / online 이 거의 동시에 들어오는데,
|
|
217
222
|
// deactivate() 를 await 하는 동안 state 는 아직 CONNECTED 라 재진입 가드가 없으면
|
|
218
223
|
// 두 번째 resume 이 그대로 통과해 클라이언트를 이중으로 만든다.
|
|
219
|
-
if (this._forceReconnectPromise) {
|
|
220
|
-
this.
|
|
221
|
-
|
|
224
|
+
if (!this._forceReconnectPromise) {
|
|
225
|
+
this._forceReconnectPromise = this._doForceReconnect(reason)
|
|
226
|
+
.finally(() => { this._forceReconnectPromise = null; });
|
|
222
227
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return this._forceReconnectPromise;
|
|
228
|
+
await this._forceReconnectPromise;
|
|
229
|
+
// 외부 호출의 완료 의미는 유지하되, 다음 복구 묶음을 막는 실행 잠금은 이미 풀었다.
|
|
230
|
+
try { await this._connectPromise; }
|
|
231
|
+
catch { /* connect/error 이벤트가 종료 사유를 전달한다. */ }
|
|
228
232
|
}
|
|
229
233
|
|
|
230
234
|
/** @private */
|
|
231
235
|
async _doForceReconnect(reason) {
|
|
236
|
+
this._cancelReconnectRecovery();
|
|
232
237
|
this.logger.info(`Force reconnect: ${reason}`);
|
|
233
238
|
|
|
234
239
|
if (this.stompClient) {
|
|
@@ -243,12 +248,16 @@ class ConnectionManager extends EventEmitter {
|
|
|
243
248
|
this.stompClient = null;
|
|
244
249
|
}
|
|
245
250
|
|
|
251
|
+
if (this._intentionalDisconnect || this._terminalAuthError) return;
|
|
252
|
+
|
|
246
253
|
// connect() 가 CONNECTED/CONNECTING 이면 조기 반환하므로 상태를 먼저 내려둔다.
|
|
247
254
|
this._setState(ConnectionState.DISCONNECTED);
|
|
248
255
|
this.reconnectAttempts = 0;
|
|
249
256
|
|
|
250
257
|
try {
|
|
251
|
-
|
|
258
|
+
// 활성화까지만 직렬화한다. connect 대기는 장애 전체에 걸쳐 유지되므로
|
|
259
|
+
// 여기서 await 하면 다음 저빈도 복구가 이 잠금에 막힌다.
|
|
260
|
+
this.connect().catch(error => this.logger.warn(`Force reconnect failed (${reason}):`, error));
|
|
252
261
|
} catch (error) {
|
|
253
262
|
this.logger.warn(`Force reconnect failed (${reason}):`, error);
|
|
254
263
|
}
|
|
@@ -288,25 +297,35 @@ class ConnectionManager extends EventEmitter {
|
|
|
288
297
|
*/
|
|
289
298
|
_createStompClient() {
|
|
290
299
|
const wsUrl = this._getWebSocketUrl();
|
|
300
|
+
// 교체된 클라이언트(정지 뒤 새 토큰으로 다시 연결 등)의 늦은 콜백은 현재 연결 상태를 바꾸지 않는다.
|
|
301
|
+
let stomp = null;
|
|
302
|
+
const current = () => !this.stompClient || this.stompClient === stomp;
|
|
291
303
|
|
|
292
304
|
const config = {
|
|
293
305
|
beforeConnect: async (client) => {
|
|
294
|
-
if (!this.getAccessToken)
|
|
306
|
+
if (!this.getAccessToken) {
|
|
307
|
+
this._connectionToken = this.jwtToken;
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
295
310
|
let attempt = 0;
|
|
296
311
|
while (!this._intentionalDisconnect && (!client || client.active)) {
|
|
297
312
|
try {
|
|
298
313
|
const token = await this.getAccessToken({ forceRefresh: this._authRejected === true });
|
|
299
314
|
if (this._intentionalDisconnect || (client && !client.active)) return;
|
|
315
|
+
if (this._authRejected && (!token || token.replace(/^Bearer\s+/i, '') === this._rejectedToken)) {
|
|
316
|
+
this._stopForAuthError(new Error('Authentication rejected without a new access token'), client);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
300
319
|
this.updateToken(token);
|
|
320
|
+
this._connectionToken = token;
|
|
301
321
|
this._authRejected = false;
|
|
302
322
|
return;
|
|
303
323
|
} catch (error) {
|
|
304
324
|
this.emit('error', { type: ErrorTypes.CONNECTION_FAILED, message: error.message, error });
|
|
325
|
+
// 거부된 access token과 갱신 요청의 일시 실패는 별개다.
|
|
326
|
+
// 새 토큰/동일 토큰 판정은 위에서, 확정된 인증 종료만 여기서 처리한다.
|
|
305
327
|
if (isTerminalAuthError(error)) {
|
|
306
|
-
this.
|
|
307
|
-
this._connectReject?.(error);
|
|
308
|
-
this._connectResolve = this._connectReject = null;
|
|
309
|
-
await (client || this.stompClient)?.deactivate();
|
|
328
|
+
this._stopForAuthError(error, client);
|
|
310
329
|
return;
|
|
311
330
|
}
|
|
312
331
|
// beforeConnect의 예외에는 STOMP 소켓 종료 백오프가 적용되지 않는다.
|
|
@@ -329,7 +348,8 @@ class ConnectionManager extends EventEmitter {
|
|
|
329
348
|
},
|
|
330
349
|
heartbeatIncoming: this.heartbeatIncoming,
|
|
331
350
|
heartbeatOutgoing: this.heartbeatOutgoing,
|
|
332
|
-
|
|
351
|
+
// stompjs는 연결 성공 시 다음 지연을 저장한다. close 콜백보다 먼저 지터를 적용한다.
|
|
352
|
+
reconnectDelay: this.reconnectDelay * (0.8 + Math.random() * 0.4),
|
|
333
353
|
// 지수 백오프 — 고정 간격이면 서버 재시작 시 전 클라이언트가 같은 주기로 몰린다.
|
|
334
354
|
// STOMP 7 내장 기능이라 직접 타이머를 굴리지 않는다.
|
|
335
355
|
reconnectTimeMode: ReconnectionTimeMode.EXPONENTIAL,
|
|
@@ -339,11 +359,11 @@ class ConnectionManager extends EventEmitter {
|
|
|
339
359
|
this.logger.debug('STOMP:', str);
|
|
340
360
|
}
|
|
341
361
|
},
|
|
342
|
-
onConnect: (frame) => this._onConnect(frame),
|
|
343
|
-
onDisconnect: (frame) => this._onDisconnect(frame),
|
|
344
|
-
onStompError: (frame) => this._onStompError(frame),
|
|
345
|
-
onWebSocketClose: (event) => this._onWebSocketClose(event),
|
|
346
|
-
onWebSocketError: (event) => this._onWebSocketError(event)
|
|
362
|
+
onConnect: (frame) => { if (current()) this._onConnect(frame); },
|
|
363
|
+
onDisconnect: (frame) => { if (current()) this._onDisconnect(frame); },
|
|
364
|
+
onStompError: (frame) => { if (current()) this._onStompError(frame); },
|
|
365
|
+
onWebSocketClose: (event) => { if (current()) this._onWebSocketClose(event); },
|
|
366
|
+
onWebSocketError: (event) => { if (current()) this._onWebSocketError(event); }
|
|
347
367
|
};
|
|
348
368
|
|
|
349
369
|
if (this.useSockJS) {
|
|
@@ -357,7 +377,8 @@ class ConnectionManager extends EventEmitter {
|
|
|
357
377
|
}
|
|
358
378
|
|
|
359
379
|
const Client = getStompClient();
|
|
360
|
-
|
|
380
|
+
stomp = new Client(config);
|
|
381
|
+
return stomp;
|
|
361
382
|
}
|
|
362
383
|
|
|
363
384
|
/**
|
|
@@ -370,29 +391,35 @@ class ConnectionManager extends EventEmitter {
|
|
|
370
391
|
return;
|
|
371
392
|
}
|
|
372
393
|
|
|
373
|
-
if (this.
|
|
374
|
-
this.
|
|
375
|
-
|
|
394
|
+
if (!this._connectPromise) {
|
|
395
|
+
this._connectPromise = new Promise((resolve, reject) => {
|
|
396
|
+
const clear = () => {
|
|
397
|
+
this._connectPromise = null;
|
|
398
|
+
this._connectResolve = this._connectReject = null;
|
|
399
|
+
};
|
|
400
|
+
this._connectResolve = () => { clear(); resolve(); };
|
|
401
|
+
this._connectReject = error => { clear(); reject(error); };
|
|
402
|
+
});
|
|
376
403
|
}
|
|
377
|
-
|
|
404
|
+
const connected = this._connectPromise;
|
|
405
|
+
if (this.stompClient && (this.state === ConnectionState.CONNECTING || this.state === ConnectionState.RECONNECTING)) {
|
|
406
|
+
return connected;
|
|
407
|
+
}
|
|
408
|
+
this._cancelReconnectRecovery();
|
|
409
|
+
this._terminalAuthError = false;
|
|
378
410
|
this._setState(ConnectionState.CONNECTING);
|
|
379
411
|
this.reconnectAttempts = 0;
|
|
380
412
|
this._intentionalDisconnect = false;
|
|
381
413
|
this._connectRequested = true;
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
this.
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
this._setState(ConnectionState.ERROR);
|
|
392
|
-
this._drainPendingSubscriptions('Connection activation failed');
|
|
393
|
-
reject(error);
|
|
394
|
-
}
|
|
395
|
-
});
|
|
414
|
+
try {
|
|
415
|
+
this.stompClient = this._createStompClient();
|
|
416
|
+
this.stompClient.activate();
|
|
417
|
+
} catch (error) {
|
|
418
|
+
this._setState(ConnectionState.ERROR);
|
|
419
|
+
this._drainPendingSubscriptions('Connection activation failed');
|
|
420
|
+
this._connectReject?.(error);
|
|
421
|
+
}
|
|
422
|
+
return connected;
|
|
396
423
|
}
|
|
397
424
|
|
|
398
425
|
/**
|
|
@@ -408,6 +435,10 @@ class ConnectionManager extends EventEmitter {
|
|
|
408
435
|
this._setState(ConnectionState.CONNECTED);
|
|
409
436
|
this.reconnectAttempts = 0;
|
|
410
437
|
this._hasConnectedBefore = true;
|
|
438
|
+
this._recoveryEligible = true;
|
|
439
|
+
this._authRejected = false;
|
|
440
|
+
this._rejectedToken = null;
|
|
441
|
+
this._notifiedRejectedToken = null;
|
|
411
442
|
this.logger.info('Connected to server', frame);
|
|
412
443
|
|
|
413
444
|
// 기존 구독 재등록 (재연결 시)
|
|
@@ -458,12 +489,13 @@ class ConnectionManager extends EventEmitter {
|
|
|
458
489
|
frame
|
|
459
490
|
};
|
|
460
491
|
|
|
461
|
-
|
|
462
|
-
this.
|
|
492
|
+
if (this._authRejected) this._recordAuthRejection();
|
|
493
|
+
const terminal = !this._canRecoverConnection();
|
|
494
|
+
if (terminal) this._drainPendingSubscriptions(`STOMP error: ${error.message}`);
|
|
463
495
|
|
|
464
496
|
this.emit('error', error);
|
|
465
497
|
|
|
466
|
-
if (this._connectReject) {
|
|
498
|
+
if (terminal && this._connectReject) {
|
|
467
499
|
this._connectReject(new Error(error.message));
|
|
468
500
|
this._connectResolve = null;
|
|
469
501
|
this._connectReject = null;
|
|
@@ -475,13 +507,12 @@ class ConnectionManager extends EventEmitter {
|
|
|
475
507
|
* @private
|
|
476
508
|
*/
|
|
477
509
|
_onWebSocketClose(event) {
|
|
478
|
-
if (
|
|
479
|
-
if (event?.code === 4001) this._authRejected = true;
|
|
510
|
+
if (event?.code === 4001) this._recordAuthRejection();
|
|
480
511
|
this.logger.warn('WebSocket closed:', event);
|
|
481
512
|
|
|
482
513
|
// forceReconnect 의 의도적 teardown 이면 재연결 시도로 집계하지 않는다 —
|
|
483
514
|
// 카운터가 오염되면 복귀 직후 다시 max 를 소진해버린다.
|
|
484
|
-
if (this._tearingDown) {
|
|
515
|
+
if (this._tearingDown || this._intentionalDisconnect || this._terminalAuthError) {
|
|
485
516
|
this.logger.debug('WebSocket closed during intentional teardown — not counting as reconnect');
|
|
486
517
|
return;
|
|
487
518
|
}
|
|
@@ -498,11 +529,8 @@ class ConnectionManager extends EventEmitter {
|
|
|
498
529
|
_onWebSocketError(event) {
|
|
499
530
|
this.logger.error('WebSocket error:', event);
|
|
500
531
|
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
// 이미 CONNECTED 후 일시 끊김은 _onWebSocketClose → _handleReconnect 로 흘러
|
|
504
|
-
// 재연결 성공 시 _processPendingSubscriptions 가 살린다.
|
|
505
|
-
if (this._connectReject) {
|
|
532
|
+
// 이전에 연결된 클라이언트의 복구 중에는 원래 호출자의 대기를 유지한다.
|
|
533
|
+
if (this._connectReject && !this._canRecoverConnection()) {
|
|
506
534
|
this._drainPendingSubscriptions('WebSocket connection failed');
|
|
507
535
|
this._connectReject(new Error('WebSocket connection failed'));
|
|
508
536
|
this._connectResolve = null;
|
|
@@ -523,17 +551,26 @@ class ConnectionManager extends EventEmitter {
|
|
|
523
551
|
this.reconnectAttempts++;
|
|
524
552
|
|
|
525
553
|
if (this.reconnectAttempts > this.maxReconnectAttempts) {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
554
|
+
const exhaustedClient = this.stompClient;
|
|
555
|
+
this.stompClient = null;
|
|
556
|
+
// 같은 STOMP 인스턴스의 타이머를 완전히 끈 뒤 저빈도 복구를 예약한다.
|
|
557
|
+
// Promise 안에서 호출을 시작하면 stompjs가 close 이후 재시도를 먼저 예약할 수 있다.
|
|
558
|
+
let deactivated;
|
|
559
|
+
try {
|
|
560
|
+
deactivated = exhaustedClient?.deactivate();
|
|
561
|
+
} catch (error) {
|
|
562
|
+
this.logger.warn('Error deactivating stomp client on max reconnect:', error);
|
|
563
|
+
}
|
|
564
|
+
Promise.resolve(deactivated).catch(error => {
|
|
565
|
+
this.logger.warn('Error deactivating stomp client on max reconnect:', error);
|
|
566
|
+
}).then(() => this._scheduleReconnectRecovery());
|
|
567
|
+
|
|
568
|
+
// 복구 묶음 사이에서도 같은 connect/subscribe 요청을 유지한다.
|
|
569
|
+
if (!this._canRecoverConnection()) {
|
|
570
|
+
this._connectReject?.(new Error('Max reconnection attempts exceeded'));
|
|
571
|
+
this._connectResolve = this._connectReject = null;
|
|
572
|
+
this._drainPendingSubscriptions('Max reconnection attempts exceeded');
|
|
533
573
|
}
|
|
534
|
-
|
|
535
|
-
// terminal 전이 — 재연결 성공으로 살릴 기회가 사라졌으므로 pending 도 drain.
|
|
536
|
-
this._drainPendingSubscriptions('Max reconnection attempts exceeded');
|
|
537
574
|
|
|
538
575
|
this._setState(ConnectionState.ERROR);
|
|
539
576
|
this.emit('error', {
|
|
@@ -548,6 +585,61 @@ class ConnectionManager extends EventEmitter {
|
|
|
548
585
|
this.logger.info(`Reconnecting... attempt ${this.reconnectAttempts}`);
|
|
549
586
|
}
|
|
550
587
|
|
|
588
|
+
/** 실제 거부된 자격 증명을 기록한다. 공급 함수가 있어도 정적 토큰일 수 있다. */
|
|
589
|
+
_recordAuthRejection() {
|
|
590
|
+
this._authRejected = true;
|
|
591
|
+
this._rejectedToken = (this._connectionToken || this.jwtToken).replace(/^Bearer\s+/i, '');
|
|
592
|
+
if (!this.getAccessToken) this._stopForAuthError(new Error('STOMP error: authentication rejected'));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
_stopForAuthError(error, client = this.stompClient) {
|
|
596
|
+
// 같은 거부 토큰은 한 번만 알린다. 오류 처리기가 connect() 를 다시 불러도 즉시 정지 → 알림이 반복되지 않는다.
|
|
597
|
+
const notify = !this._terminalAuthError && this._usesStaticToken() && this._rejectedToken !== this._notifiedRejectedToken;
|
|
598
|
+
this._terminalAuthError = true;
|
|
599
|
+
this._recoveryEligible = false;
|
|
600
|
+
this._cancelReconnectRecovery();
|
|
601
|
+
this._setState(ConnectionState.ERROR);
|
|
602
|
+
this._connectReject?.(error);
|
|
603
|
+
this._drainPendingSubscriptions(error.message);
|
|
604
|
+
// beforeConnect 안에서도 호출된다. 여기서 deactivate 완료를 기다리지 않는다.
|
|
605
|
+
Promise.resolve(client?.deactivate()).catch(failure => this.logger.warn('Auth teardown failed:', failure));
|
|
606
|
+
// 정적 토큰 모드는 정지를 오류 이벤트(FE connectionError)로 알린다. 새 토큰은 updateToken() 으로 온다.
|
|
607
|
+
// 정지 전이(이 함수와 호출한 _onStompError 의 나머지)를 마친 뒤 알린다. 처리기의 updateToken()·connect() 가 정리 중인 연결과 섞이지 않는다.
|
|
608
|
+
if (notify) {
|
|
609
|
+
this._notifiedRejectedToken = this._rejectedToken;
|
|
610
|
+
Promise.resolve().then(() => this.emit('error', { type: ErrorTypes.CONNECTION_LOST, message: error.message, error }));
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** 갱신 세션이 없는 정적 토큰(레거시) 모드. canRefreshToken 은 TalkFlowClient 가 설치한다. */
|
|
615
|
+
_usesStaticToken() {
|
|
616
|
+
return !this.getAccessToken || this.canRefreshToken?.() === false;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
_canRecoverConnection() {
|
|
620
|
+
return this._recoveryEligible && !this._intentionalDisconnect && !this._terminalAuthError
|
|
621
|
+
&& this.reconnectRecoveryDelay > 0;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** 기존에 연결됐던 클라이언트만 긴 장애 후 탭 이벤트 없이 다시 시도한다. */
|
|
625
|
+
_scheduleReconnectRecovery() {
|
|
626
|
+
if (!this._canRecoverConnection() || this.state !== ConnectionState.ERROR || this.stompClient) return;
|
|
627
|
+
this._cancelReconnectRecovery();
|
|
628
|
+
const delay = this.reconnectRecoveryDelay * (0.8 + Math.random() * 0.4);
|
|
629
|
+
this._recoveryTimer = setTimeout(() => {
|
|
630
|
+
this._recoveryTimer = null;
|
|
631
|
+
if (!this._intentionalDisconnect && !this._terminalAuthError && this.state === ConnectionState.ERROR) {
|
|
632
|
+
this.forceReconnect('retry-cooldown');
|
|
633
|
+
}
|
|
634
|
+
}, delay);
|
|
635
|
+
this._recoveryTimer.unref?.();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
_cancelReconnectRecovery() {
|
|
639
|
+
clearTimeout(this._recoveryTimer);
|
|
640
|
+
this._recoveryTimer = null;
|
|
641
|
+
}
|
|
642
|
+
|
|
551
643
|
/**
|
|
552
644
|
* 기존 구독 재등록 (재연결 시)
|
|
553
645
|
* @private
|
|
@@ -684,6 +776,13 @@ class ConnectionManager extends EventEmitter {
|
|
|
684
776
|
'Authorization': token.startsWith('Bearer ') ? token : `Bearer ${token}`
|
|
685
777
|
};
|
|
686
778
|
}
|
|
779
|
+
|
|
780
|
+
// 정적 토큰 모드에서 거부된 토큰으로 정지했다면 새 토큰으로 다시 연결한다. 같은 토큰이면 정지를 유지한다.
|
|
781
|
+
// connect() 는 진행 중인 연결을 공유한다. connect(jwt) 가 이어서 connect() 를 불러도 클라이언트는 하나다.
|
|
782
|
+
if (this._terminalAuthError && !this._intentionalDisconnect && this._usesStaticToken()
|
|
783
|
+
&& token.replace(/^Bearer\s+/i, '') !== this._rejectedToken) {
|
|
784
|
+
this.connect().catch(error => this.logger.warn('Reconnect with updated token failed:', error));
|
|
785
|
+
}
|
|
687
786
|
}
|
|
688
787
|
|
|
689
788
|
/**
|
|
@@ -695,6 +794,10 @@ class ConnectionManager extends EventEmitter {
|
|
|
695
794
|
async disconnect() {
|
|
696
795
|
// 명시적 해제 — 포그라운드 복귀가 이걸 되살리면 안 된다.
|
|
697
796
|
this._intentionalDisconnect = true;
|
|
797
|
+
this._recoveryEligible = false;
|
|
798
|
+
this._authRejected = false;
|
|
799
|
+
this._rejectedToken = null;
|
|
800
|
+
this._cancelReconnectRecovery();
|
|
698
801
|
this._connectRequested = false;
|
|
699
802
|
this._cancelAuthRetry?.();
|
|
700
803
|
this._connectReject?.(Object.assign(new Error('Connection cancelled'), { code: 'AUTH_OPERATION_CANCELLED' }));
|
package/src/index.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* import TalkFlowClient from '@talkflow/sdk';
|
|
15
15
|
*
|
|
16
16
|
* // 1단계: 로그인. 클라이언트 생성 «전» 에 부른다 — ck/projectId/JWT 를 한 번에 받는다.
|
|
17
|
+
* // 회사 계정이 둘 이상이면 먼저 { workspaceSelectionRequired, workspaces } 가 온다 → projectId 를 실어 다시 부른다.
|
|
17
18
|
* const { clientKey, projectId, accessToken } = await TalkFlowClient.loginUser({
|
|
18
19
|
* serverUrl: 'https://chat.apiorbit.net',
|
|
19
20
|
* email: 'me@example.com',
|
|
@@ -68,6 +68,7 @@ const WEBRTC_EVENT_MAP = [
|
|
|
68
68
|
['mediaStateChanged', 'mediaStateChanged'],
|
|
69
69
|
['callStarted', 'callStarted'],
|
|
70
70
|
['callEnded', 'callEnded'],
|
|
71
|
+
['callRecoveryFailed', 'callRecoveryFailed'],
|
|
71
72
|
['callRequested', 'callRequested'],
|
|
72
73
|
['callAccepted', 'callAccepted'],
|
|
73
74
|
['callRejected', 'callRejected'],
|
package/src/talkflow/session.js
CHANGED
|
@@ -23,6 +23,7 @@ export function initializeSubClients(client) {
|
|
|
23
23
|
reconnectDelay: client.options.reconnectDelay,
|
|
24
24
|
maxReconnectDelay: client.options.maxReconnectDelay,
|
|
25
25
|
maxReconnectAttempts: client.options.maxReconnectAttempts,
|
|
26
|
+
reconnectRecoveryDelay: client.options.reconnectRecoveryDelay,
|
|
26
27
|
heartbeatIncoming: client.options.heartbeatIncoming,
|
|
27
28
|
heartbeatOutgoing: client.options.heartbeatOutgoing,
|
|
28
29
|
resumeOnVisibility: client.options.resumeOnVisibility,
|
|
@@ -32,6 +33,8 @@ export function initializeSubClients(client) {
|
|
|
32
33
|
if (!client.authSession) return client.options.jwtToken;
|
|
33
34
|
return client.authSession.getAccessToken({ forceRefresh, rejectedToken: client.connectionManager?.jwtToken });
|
|
34
35
|
};
|
|
36
|
+
// 레거시 모드의 공급자는 정적 토큰을 돌려줄 뿐이다. 새 토큰은 updateToken() 으로만 온다.
|
|
37
|
+
client.connectionManager.canRefreshToken = () => !!client.authSession;
|
|
35
38
|
|
|
36
39
|
client._chat = new ChatClient({
|
|
37
40
|
connectionManager: client.connectionManager,
|
|
@@ -91,6 +91,15 @@ class PeerConnectionManager extends EventEmitter {
|
|
|
91
91
|
return connection;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** 그룹 시작 중 answer를 먼저 보낸 peer에 로컬 트랙을 보낼 협상을 재개한다. */
|
|
95
|
+
async resumeAutoNegotiation(peerId) {
|
|
96
|
+
const peer = this.peers.get(peerId);
|
|
97
|
+
if (!peer || peer.connection.signalingState !== 'stable') return false;
|
|
98
|
+
peer.skipAutoNegotiation = false;
|
|
99
|
+
await peer.connection.onnegotiationneeded();
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
94
103
|
/**
|
|
95
104
|
* 연결 이벤트 핸들러 설정
|
|
96
105
|
* @private
|
|
@@ -146,7 +155,7 @@ class PeerConnectionManager extends EventEmitter {
|
|
|
146
155
|
// 협상 필요 (Perfect Negotiation)
|
|
147
156
|
connection.onnegotiationneeded = async () => {
|
|
148
157
|
// 1:1 통화에서 수동으로 offer를 생성할 때는 자동 negotiation 건너뛰기
|
|
149
|
-
if (peerState.skipAutoNegotiation) {
|
|
158
|
+
if (peerState.skipAutoNegotiation || peerState.makingOffer || connection.signalingState !== 'stable') {
|
|
150
159
|
this.logger.debug(`Skipping auto negotiation for ${peerId} (manual offer will be sent)`);
|
|
151
160
|
return;
|
|
152
161
|
}
|