@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/dist/index.d.ts +108 -2
- 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 +1 -0
- package/src/chat/ChatClient.js +110 -17
- package/src/constants.js +5 -0
- package/src/core/ConnectionManager.js +166 -63
- package/src/push/PushManager.js +72 -0
- package/src/talkflow/delegates.js +3 -0
- package/src/talkflow/eventForwarding.js +8 -0
- package/src/talkflow/mailNotifications.js +154 -0
- package/src/talkflow/session.js +14 -0
- package/src/webrtc/PeerConnectionManager.js +10 -1
- package/src/webrtc/WebRTCClient.js +287 -28
- package/types/index.d.ts +108 -2
package/package.json
CHANGED
package/src/TalkFlowClient.js
CHANGED
|
@@ -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 로 보존 —
|
package/src/chat/ChatClient.js
CHANGED
|
@@ -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<
|
|
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)
|
|
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
|
-
|
|
3813
|
-
|
|
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.
|
|
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' }));
|