@vibexnpm/talkx 8.5.0 → 8.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibexnpm/talkx",
3
- "version": "8.5.0",
3
+ "version": "8.6.0",
4
4
  "type": "module",
5
5
  "description": "TalkFlow SDK - Chat & WebRTC unified client",
6
6
  "main": "dist/talkflow-sdk.umd.js",
@@ -72,6 +72,7 @@ class TalkFlowClient extends EventEmitter {
72
72
  reconnectDelay: options.reconnectDelay || DefaultConfig.reconnectDelay,
73
73
  maxReconnectDelay: options.maxReconnectDelay || DefaultConfig.maxReconnectDelay,
74
74
  maxReconnectAttempts: options.maxReconnectAttempts || DefaultConfig.maxReconnectAttempts,
75
+ reconnectRecoveryDelay: options.reconnectRecoveryDelay ?? DefaultConfig.reconnectRecoveryDelay,
75
76
  heartbeatIncoming: options.heartbeatIncoming || DefaultConfig.heartbeatIncoming,
76
77
  heartbeatOutgoing: options.heartbeatOutgoing || DefaultConfig.heartbeatOutgoing,
77
78
  // 탭 복귀 / 네트워크 복구 시 자동 재연결. false 로 꺼야 하므로 !== false 로 보존 —
@@ -7,7 +7,7 @@
7
7
 
8
8
  import EventEmitter from '../utils/EventEmitter.js';
9
9
  import Logger from '../utils/Logger.js';
10
- import { WebSocketPaths, LogLevel, RoomListEventType, ChatRoomType, DefaultConfig } from '../constants.js';
10
+ import { WebSocketPaths, LogLevel, RoomListEventType, ChatRoomType, DefaultConfig, ConnectionState } from '../constants.js';
11
11
 
12
12
  const MAX_FILES_PER_MESSAGE = 20;
13
13
 
@@ -186,6 +186,8 @@ class ChatClient extends EventEmitter {
186
186
  // 상수로 두면 maxSyncPages 튜닝 시 조용히 어긋나므로 config 에서 파생시킨다 (설계 §7).
187
187
  this._maxSeenPerRoom = Math.max(200, this.maxSyncPages * this.syncPageSize + 100);
188
188
 
189
+ this._reconnectSyncGeneration = 0;
190
+ this._delayedReconnectSyncTimer = null;
189
191
  this._installSyncTriggers();
190
192
  }
191
193
 
@@ -205,8 +207,20 @@ class ChatClient extends EventEmitter {
205
207
  // syncOnReconnect 옵션 + anchor 있는 방이라는 두 겹 필터가 걸려 있어서, 거기 얹으면 옵션이
206
208
  // 꺼져 있거나 그 방에서 메시지를 받은 적이 없을 때 replay 가 조용히 빠진다.
207
209
  this._onReconnectedForSync = ({ attempts } = {}) => {
210
+ this._cancelDelayedReconnectSync();
211
+ const generation = this._reconnectSyncGeneration;
208
212
  this.syncAfterReconnect(`reconnected:${attempts ?? 0}`)
209
- .catch((error) => this.logger.warn('Gap sync after reconnect failed:', error));
213
+ .catch((error) => this.logger.warn('Gap sync after reconnect failed:', error))
214
+ .then((cursors) => {
215
+ if (!this.syncOnReconnect || !cursors || generation !== this._reconnectSyncGeneration) return;
216
+ // SUBSCRIBE 전송과 SimpleBroker 등록은 다른 시점이다. 첫 snapshot 뒤의 공백을 다시 읽는다.
217
+ this._delayedReconnectSyncTimer = setTimeout(() => {
218
+ this._delayedReconnectSyncTimer = null;
219
+ this._supplementAfterReconnect(cursors, generation)
220
+ .catch(error => this.logger.warn('Delayed gap sync failed:', error));
221
+ }, 1000);
222
+ this._delayedReconnectSyncTimer.unref?.();
223
+ });
210
224
  this.resumeAssistantStreams(`reconnected:${attempts ?? 0}`)
211
225
  .catch((error) => this.logger.warn('Assistant stream replay after reconnect failed:', error));
212
226
  };
@@ -217,6 +231,10 @@ class ChatClient extends EventEmitter {
217
231
  .catch((error) => this.logger.warn('Assistant stream replay after resume failed:', error));
218
232
  };
219
233
 
234
+ this._onStateChangeForSync = ({ state }) => {
235
+ if (state !== ConnectionState.CONNECTED) this._cancelDelayedReconnectSync();
236
+ };
237
+ this.connectionManager.on('stateChange', this._onStateChangeForSync);
220
238
  this.connectionManager.on('reconnected', this._onReconnectedForSync);
221
239
  this.connectionManager.on('resumed', this._onResumedForSync);
222
240
  }
@@ -1596,8 +1614,7 @@ class ChatClient extends EventEmitter {
1596
1614
  * @private
1597
1615
  * @returns {Promise<Object|null>} 복구 결과. 대상이 아니면 null.
1598
1616
  */
1599
- async _syncRoom(roomId) {
1600
- const anchor = this._anchor.get(roomId);
1617
+ async _syncRoom(roomId, anchor = this._anchor.get(roomId), quiet = false) {
1601
1618
  // 이 세션에서 아직 받은 메시지가 없는 방 — enterRoom 의 초기 로드가 담당한다.
1602
1619
  if (!anchor) return null;
1603
1620
  if (this._syncInFlight.has(roomId)) return null;
@@ -1631,25 +1648,25 @@ class ChatClient extends EventEmitter {
1631
1648
  if (hasMore) {
1632
1649
  // 상한 초과 — 버퍼를 통째로 버리고 최신 페이지로 전환한다(부분 replay 금지).
1633
1650
  this.logger.info(`Gap too large, falling back to latest page: room=${roomId}`);
1634
- this.emit('gapTruncated', { roomId, fetched: buffer.length });
1651
+ if (!quiet) this.emit('gapTruncated', { roomId, fetched: buffer.length });
1635
1652
  const reloaded = await this._reloadLatestPage(roomId, genAtStart);
1636
1653
  return {
1637
- roomId, recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt
1654
+ roomId, cursor: this._anchor.get(roomId), recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt
1638
1655
  };
1639
1656
  }
1640
1657
 
1641
1658
  const recovered = this._replayMessages(roomId, buffer);
1642
1659
  this._commitAnchorFromSync(roomId, cursor, genAtStart);
1643
- return { roomId, recovered, truncated: false, durationMs: Date.now() - startedAt };
1660
+ return { roomId, cursor, recovered, truncated: false, durationMs: Date.now() - startedAt };
1644
1661
 
1645
1662
  } catch (error) {
1646
1663
  if (this._isCursorExpired(error)) {
1647
1664
  // retention 정리 등으로 앵커가 사라짐 — 최신 페이지로 복구한다.
1648
1665
  this.logger.warn(`Sync cursor expired, reloading latest: room=${roomId}`);
1649
- this.emit('gapTruncated', { roomId, fetched: 0, reason: 'cursorExpired' });
1666
+ if (!quiet) this.emit('gapTruncated', { roomId, fetched: 0, reason: 'cursorExpired' });
1650
1667
  const reloaded = await this._reloadLatestPage(roomId, genAtStart);
1651
1668
  return {
1652
- roomId, recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt
1669
+ roomId, cursor: this._anchor.get(roomId), recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt
1653
1670
  };
1654
1671
  }
1655
1672
  throw error;
@@ -1665,7 +1682,7 @@ class ChatClient extends EventEmitter {
1665
1682
  * 상호 배타적이라 중복 실행되지 않는다.</p>
1666
1683
  *
1667
1684
  * @param {string} [reason]
1668
- * @returns {Promise<void>}
1685
+ * @returns {Promise<Map<string, string>|undefined>} 1차 조회 cursor — 내부 보충 조회용.
1669
1686
  */
1670
1687
  async syncAfterReconnect(reason = 'manual') {
1671
1688
  if (!this.syncOnReconnect) return;
@@ -1679,11 +1696,15 @@ class ChatClient extends EventEmitter {
1679
1696
  this.emit('syncStarted', { roomIds, reason });
1680
1697
  const startedAt = Date.now();
1681
1698
  const rooms = [];
1699
+ const cursors = new Map();
1682
1700
 
1683
1701
  for (const roomId of roomIds) {
1684
1702
  try {
1685
1703
  const result = await this._syncRoom(roomId);
1686
- if (result) rooms.push(result);
1704
+ if (result) {
1705
+ rooms.push(result);
1706
+ cursors.set(roomId, result.cursor);
1707
+ }
1687
1708
  } catch (error) {
1688
1709
  // 한 방의 실패가 나머지 방 복구를 막지 않게 한다.
1689
1710
  this.logger.warn(`Gap sync failed: room=${roomId}`, error);
@@ -1698,6 +1719,17 @@ class ChatClient extends EventEmitter {
1698
1719
  durationMs: Date.now() - startedAt,
1699
1720
  reason
1700
1721
  });
1722
+ return cursors;
1723
+ }
1724
+
1725
+ /** 첫 조회가 끝난 cursor부터 조용히 보충한다. 목록·동기화 이벤트는 첫 조회의 책임이다. */
1726
+ async _supplementAfterReconnect(cursors, generation) {
1727
+ for (const [roomId, cursor] of cursors) {
1728
+ if (generation !== this._reconnectSyncGeneration) return;
1729
+ if (!this.subscribedRooms.has(roomId)) continue;
1730
+ try { await this._syncRoom(roomId, cursor, true); }
1731
+ catch (error) { this.logger.warn(`Supplemental gap sync failed: room=${roomId}`, error); }
1732
+ }
1701
1733
  }
1702
1734
 
1703
1735
  /**
@@ -3864,13 +3896,16 @@ class ChatClient extends EventEmitter {
3864
3896
  this.logger.setLevel(level);
3865
3897
  }
3866
3898
 
3867
- /**
3868
- * 리소스 정리.
3869
- *
3870
- * <p>{@link #unsubscribeRoom} 에서 방별 타이머를 정리하지만, 구독 목록에 없는
3871
- * roomId 의 debounce 타이머가 남아 있을 수 있어 Map 전체를 명시적으로 비운다.</p>
3872
- */
3899
+ _cancelDelayedReconnectSync() {
3900
+ this._reconnectSyncGeneration++;
3901
+ clearTimeout(this._delayedReconnectSyncTimer);
3902
+ this._delayedReconnectSyncTimer = null;
3903
+ }
3904
+
3905
+ /** 구독, 예약된 복구, 방별 진행 표시를 정리한다. */
3873
3906
  destroy() {
3907
+ this._cancelDelayedReconnectSync();
3908
+ this.connectionManager.off?.('stateChange', this._onStateChangeForSync);
3874
3909
  this.unsubscribeAllRooms();
3875
3910
  if (this.roomListSubscribed) {
3876
3911
  this.unsubscribeRoomList();
package/src/constants.js CHANGED
@@ -438,6 +438,7 @@ export const DefaultConfig = {
438
438
  // 지수 백오프 상한 — 서버 재시작 시 전 클라이언트가 고정 간격으로 몰리는 것을 완화.
439
439
  maxReconnectDelay: 30000,
440
440
  maxReconnectAttempts: 10,
441
+ reconnectRecoveryDelay: 60000,
441
442
  heartbeatIncoming: 10000,
442
443
  heartbeatOutgoing: 10000,
443
444
 
@@ -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' }));
@@ -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
  }