@vibexnpm/talkx 8.4.1 → 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.4.1",
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
 
@@ -49,6 +49,9 @@ class ChatClient extends EventEmitter {
49
49
  // 채팅방 리스트 구독 상태 (카톡 스타일 리스트 실시간 업데이트)
50
50
  this.roomListSubscribed = false;
51
51
 
52
+ // Gmail 메일함 변화 구독 상태 — 자동 구독하지 않는다(Gmail 을 연결한 화면에서 subscribeMail()).
53
+ this.mailSubscribed = false;
54
+
52
55
  // 현재 보고 있는 채팅방 ID (이 방에서만 자동 읽음 처리)
53
56
  this._activeRoomId = null;
54
57
 
@@ -183,6 +186,8 @@ class ChatClient extends EventEmitter {
183
186
  // 상수로 두면 maxSyncPages 튜닝 시 조용히 어긋나므로 config 에서 파생시킨다 (설계 §7).
184
187
  this._maxSeenPerRoom = Math.max(200, this.maxSyncPages * this.syncPageSize + 100);
185
188
 
189
+ this._reconnectSyncGeneration = 0;
190
+ this._delayedReconnectSyncTimer = null;
186
191
  this._installSyncTriggers();
187
192
  }
188
193
 
@@ -202,8 +207,20 @@ class ChatClient extends EventEmitter {
202
207
  // syncOnReconnect 옵션 + anchor 있는 방이라는 두 겹 필터가 걸려 있어서, 거기 얹으면 옵션이
203
208
  // 꺼져 있거나 그 방에서 메시지를 받은 적이 없을 때 replay 가 조용히 빠진다.
204
209
  this._onReconnectedForSync = ({ attempts } = {}) => {
210
+ this._cancelDelayedReconnectSync();
211
+ const generation = this._reconnectSyncGeneration;
205
212
  this.syncAfterReconnect(`reconnected:${attempts ?? 0}`)
206
- .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
+ });
207
224
  this.resumeAssistantStreams(`reconnected:${attempts ?? 0}`)
208
225
  .catch((error) => this.logger.warn('Assistant stream replay after reconnect failed:', error));
209
226
  };
@@ -214,6 +231,10 @@ class ChatClient extends EventEmitter {
214
231
  .catch((error) => this.logger.warn('Assistant stream replay after resume failed:', error));
215
232
  };
216
233
 
234
+ this._onStateChangeForSync = ({ state }) => {
235
+ if (state !== ConnectionState.CONNECTED) this._cancelDelayedReconnectSync();
236
+ };
237
+ this.connectionManager.on('stateChange', this._onStateChangeForSync);
217
238
  this.connectionManager.on('reconnected', this._onReconnectedForSync);
218
239
  this.connectionManager.on('resumed', this._onResumedForSync);
219
240
  }
@@ -1593,8 +1614,7 @@ class ChatClient extends EventEmitter {
1593
1614
  * @private
1594
1615
  * @returns {Promise<Object|null>} 복구 결과. 대상이 아니면 null.
1595
1616
  */
1596
- async _syncRoom(roomId) {
1597
- const anchor = this._anchor.get(roomId);
1617
+ async _syncRoom(roomId, anchor = this._anchor.get(roomId), quiet = false) {
1598
1618
  // 이 세션에서 아직 받은 메시지가 없는 방 — enterRoom 의 초기 로드가 담당한다.
1599
1619
  if (!anchor) return null;
1600
1620
  if (this._syncInFlight.has(roomId)) return null;
@@ -1628,25 +1648,25 @@ class ChatClient extends EventEmitter {
1628
1648
  if (hasMore) {
1629
1649
  // 상한 초과 — 버퍼를 통째로 버리고 최신 페이지로 전환한다(부분 replay 금지).
1630
1650
  this.logger.info(`Gap too large, falling back to latest page: room=${roomId}`);
1631
- this.emit('gapTruncated', { roomId, fetched: buffer.length });
1651
+ if (!quiet) this.emit('gapTruncated', { roomId, fetched: buffer.length });
1632
1652
  const reloaded = await this._reloadLatestPage(roomId, genAtStart);
1633
1653
  return {
1634
- roomId, recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt
1654
+ roomId, cursor: this._anchor.get(roomId), recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt
1635
1655
  };
1636
1656
  }
1637
1657
 
1638
1658
  const recovered = this._replayMessages(roomId, buffer);
1639
1659
  this._commitAnchorFromSync(roomId, cursor, genAtStart);
1640
- return { roomId, recovered, truncated: false, durationMs: Date.now() - startedAt };
1660
+ return { roomId, cursor, recovered, truncated: false, durationMs: Date.now() - startedAt };
1641
1661
 
1642
1662
  } catch (error) {
1643
1663
  if (this._isCursorExpired(error)) {
1644
1664
  // retention 정리 등으로 앵커가 사라짐 — 최신 페이지로 복구한다.
1645
1665
  this.logger.warn(`Sync cursor expired, reloading latest: room=${roomId}`);
1646
- this.emit('gapTruncated', { roomId, fetched: 0, reason: 'cursorExpired' });
1666
+ if (!quiet) this.emit('gapTruncated', { roomId, fetched: 0, reason: 'cursorExpired' });
1647
1667
  const reloaded = await this._reloadLatestPage(roomId, genAtStart);
1648
1668
  return {
1649
- roomId, recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt
1669
+ roomId, cursor: this._anchor.get(roomId), recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt
1650
1670
  };
1651
1671
  }
1652
1672
  throw error;
@@ -1662,7 +1682,7 @@ class ChatClient extends EventEmitter {
1662
1682
  * 상호 배타적이라 중복 실행되지 않는다.</p>
1663
1683
  *
1664
1684
  * @param {string} [reason]
1665
- * @returns {Promise<void>}
1685
+ * @returns {Promise<Map<string, string>|undefined>} 1차 조회 cursor — 내부 보충 조회용.
1666
1686
  */
1667
1687
  async syncAfterReconnect(reason = 'manual') {
1668
1688
  if (!this.syncOnReconnect) return;
@@ -1676,11 +1696,15 @@ class ChatClient extends EventEmitter {
1676
1696
  this.emit('syncStarted', { roomIds, reason });
1677
1697
  const startedAt = Date.now();
1678
1698
  const rooms = [];
1699
+ const cursors = new Map();
1679
1700
 
1680
1701
  for (const roomId of roomIds) {
1681
1702
  try {
1682
1703
  const result = await this._syncRoom(roomId);
1683
- if (result) rooms.push(result);
1704
+ if (result) {
1705
+ rooms.push(result);
1706
+ cursors.set(roomId, result.cursor);
1707
+ }
1684
1708
  } catch (error) {
1685
1709
  // 한 방의 실패가 나머지 방 복구를 막지 않게 한다.
1686
1710
  this.logger.warn(`Gap sync failed: room=${roomId}`, error);
@@ -1695,6 +1719,17 @@ class ChatClient extends EventEmitter {
1695
1719
  durationMs: Date.now() - startedAt,
1696
1720
  reason
1697
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
+ }
1698
1733
  }
1699
1734
 
1700
1735
  /**
@@ -2732,6 +2767,61 @@ class ChatClient extends EventEmitter {
2732
2767
  return this.roomListSubscribed;
2733
2768
  }
2734
2769
 
2770
+ /**
2771
+ * Gmail 메일함 변화 구독 — {@code /user/queue/mail}. 받은 이벤트를 {@code mailChanged} 로 그대로 낸다.
2772
+ *
2773
+ * <p>새 메일 알림은 최상위 클라이언트의 {@code newMail} 을 쓴다 — 웹소켓 이벤트의 미리보기와 포그라운드 FCM 메일 푸시를
2774
+ * 합쳐 메일마다 한 번만 낸다. 재연결 때는 연결 관리자가 구독을 되살린다(이벤트는 최대 한 번 전달이라 재연결·창 포커스 때
2775
+ * 안읽은 수와 목록을 다시 읽는다).</p>
2776
+ */
2777
+ async subscribeMail() {
2778
+ if (this.mailSubscribed) {
2779
+ this.logger.debug('Already subscribed to mail');
2780
+ return;
2781
+ }
2782
+
2783
+ await this.connectionManager.subscribe(WebSocketPaths.MAIL_USER_DESTINATION, (event) => {
2784
+ this._handleMailEvent(event);
2785
+ });
2786
+
2787
+ this.mailSubscribed = true;
2788
+ this.logger.info('Subscribed to mail');
2789
+ this.emit('mailSubscribed', {});
2790
+ }
2791
+
2792
+ /**
2793
+ * Gmail 메일함 변화 구독 해제.
2794
+ */
2795
+ unsubscribeMail() {
2796
+ if (!this.mailSubscribed) {
2797
+ return;
2798
+ }
2799
+
2800
+ this.connectionManager.unsubscribe(WebSocketPaths.MAIL_USER_DESTINATION);
2801
+ this.mailSubscribed = false;
2802
+
2803
+ this.logger.info('Unsubscribed from mail');
2804
+ this.emit('mailUnsubscribed', {});
2805
+ }
2806
+
2807
+ /**
2808
+ * Gmail 메일함 변화 구독 여부.
2809
+ * @returns {boolean}
2810
+ */
2811
+ isMailSubscribed() {
2812
+ return this.mailSubscribed;
2813
+ }
2814
+
2815
+ /** 메일 이벤트 — 보낸 사람·제목이 실리므로 내용은 로그에 남기지 않는다(연결 ID·건수만). */
2816
+ _handleMailEvent(event) {
2817
+ if (!event || event.eventType !== 'MAIL_CHANGED') {
2818
+ return;
2819
+ }
2820
+ this.logger.debug('Mail changed:', event.connectionId,
2821
+ Array.isArray(event.messages) ? event.messages.length : 0);
2822
+ this.emit('mailChanged', event);
2823
+ }
2824
+
2735
2825
  /**
2736
2826
  * 메시지 읽음 처리
2737
2827
  * @param {string} roomId - 채팅방 ID
@@ -3806,13 +3896,16 @@ class ChatClient extends EventEmitter {
3806
3896
  this.logger.setLevel(level);
3807
3897
  }
3808
3898
 
3809
- /**
3810
- * 리소스 정리.
3811
- *
3812
- * <p>{@link #unsubscribeRoom} 에서 방별 타이머를 정리하지만, 구독 목록에 없는
3813
- * roomId 의 debounce 타이머가 남아 있을 수 있어 Map 전체를 명시적으로 비운다.</p>
3814
- */
3899
+ _cancelDelayedReconnectSync() {
3900
+ this._reconnectSyncGeneration++;
3901
+ clearTimeout(this._delayedReconnectSyncTimer);
3902
+ this._delayedReconnectSyncTimer = null;
3903
+ }
3904
+
3905
+ /** 구독, 예약된 복구, 방별 진행 표시를 정리한다. */
3815
3906
  destroy() {
3907
+ this._cancelDelayedReconnectSync();
3908
+ this.connectionManager.off?.('stateChange', this._onStateChangeForSync);
3816
3909
  this.unsubscribeAllRooms();
3817
3910
  if (this.roomListSubscribed) {
3818
3911
  this.unsubscribeRoomList();
package/src/constants.js CHANGED
@@ -384,6 +384,10 @@ export const WebSocketPaths = {
384
384
  // 클라이언트는 "/user/queue/rooms" 구독 (Spring STOMP 가 세션별 자동 격리)
385
385
  ROOM_LIST_USER_DESTINATION: '/user/queue/rooms',
386
386
 
387
+ // Gmail 메일함 변화 (서버 GMAIL_MAILBOX_FEATURES_DESIGN.md §6-6·§6-7)
388
+ // 서버가 convertAndSendToUser(userId, "/queue/mail", MAIL_CHANGED) 로 전송 — 새 메일이면 messages[] 미리보기 포함
389
+ MAIL_USER_DESTINATION: '/user/queue/mail',
390
+
387
391
  // WebRTC destinations
388
392
  getWebRTCDestination: (roomId) => `/topic/webrtc/${roomId}`,
389
393
  getWebRTCUserDestination: () => `/user/queue/webrtc`,
@@ -434,6 +438,7 @@ export const DefaultConfig = {
434
438
  // 지수 백오프 상한 — 서버 재시작 시 전 클라이언트가 고정 간격으로 몰리는 것을 완화.
435
439
  maxReconnectDelay: 30000,
436
440
  maxReconnectAttempts: 10,
441
+ reconnectRecoveryDelay: 60000,
437
442
  heartbeatIncoming: 10000,
438
443
  heartbeatOutgoing: 10000,
439
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' }));