@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.
@@ -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.logger.debug(`Force reconnect already in progress, joining: ${reason}`);
221
- return this._forceReconnectPromise;
224
+ if (!this._forceReconnectPromise) {
225
+ this._forceReconnectPromise = this._doForceReconnect(reason)
226
+ .finally(() => { this._forceReconnectPromise = null; });
222
227
  }
223
-
224
- this._forceReconnectPromise = this._doForceReconnect(reason)
225
- .finally(() => { this._forceReconnectPromise = null; });
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
- await this.connect();
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) return;
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._setState(ConnectionState.ERROR);
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
- reconnectDelay: this.reconnectDelay,
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
- return new Client(config);
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.state === ConnectionState.CONNECTING) {
374
- this.logger.warn('Connection in progress');
375
- return;
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
- return new Promise((resolve, reject) => {
384
- this._connectResolve = resolve;
385
- this._connectReject = reject;
386
-
387
- try {
388
- this.stompClient = this._createStompClient();
389
- this.stompClient.activate();
390
- } catch (error) {
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
- // terminal 전이 — 대기 중 구독 Promise 가 영구 hang 되지 않도록 drain.
462
- this._drainPendingSubscriptions(`STOMP error: ${error.message}`);
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 (this.stompClient && event?.code === 1012) this.stompClient.reconnectDelay = this.reconnectDelay * (0.8 + Math.random() * 0.4);
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
- // 초기 연결 실패 경로 — _connectReject 가 살아있다는 건 아직 한 번도
502
- // CONNECTED 된 적이 없다는 뜻. pending 구독도 함께 drain 해서 호출자 hang 방지.
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
- if (this.stompClient) {
527
- try {
528
- this.stompClient.deactivate();
529
- } catch (error) {
530
- this.logger.warn('Error deactivating stomp client on max reconnect:', error);
531
- }
532
- this.stompClient = null;
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'],
@@ -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
  }