@vibexnpm/talkx 3.0.5 → 3.0.7

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": "3.0.5",
3
+ "version": "3.0.7",
4
4
  "type": "module",
5
5
  "description": "TalkFlow SDK - Chat & WebRTC unified client",
6
6
  "main": "dist/talkflow-sdk.umd.js",
@@ -46,6 +46,8 @@
46
46
  "@rollup/plugin-node-resolve": "^16.0.3",
47
47
  "@rollup/plugin-replace": "^6.0.3",
48
48
  "@rollup/plugin-terser": "^0.4.4",
49
+ "babel-jest": "^29.7.0",
50
+ "jest": "^29.7.0",
49
51
  "rollup": "^4.12.0"
50
52
  }
51
53
  }
@@ -72,7 +72,20 @@ class TalkFlowClient extends EventEmitter {
72
72
  jwtToken: options.jwtToken || null,
73
73
  useSockJS: options.useSockJS !== false,
74
74
  reconnectDelay: options.reconnectDelay || DefaultConfig.reconnectDelay,
75
+ maxReconnectDelay: options.maxReconnectDelay || DefaultConfig.maxReconnectDelay,
75
76
  maxReconnectAttempts: options.maxReconnectAttempts || DefaultConfig.maxReconnectAttempts,
77
+ heartbeatIncoming: options.heartbeatIncoming || DefaultConfig.heartbeatIncoming,
78
+ heartbeatOutgoing: options.heartbeatOutgoing || DefaultConfig.heartbeatOutgoing,
79
+ // 탭 복귀 / 네트워크 복구 시 자동 재연결. false 로 꺼야 하므로 !== false 로 보존 —
80
+ // options 에 담지 않으면 undefined 가 전달되어 ConnectionManager 기본값 true 로 되살아난다.
81
+ resumeOnVisibility: options.resumeOnVisibility !== false,
82
+ // 갭 복구 (재연결/복귀 후 끊긴 구간 메시지 재조회). false 로 꺼야 하므로 !== false 로 보존.
83
+ syncOnReconnect: options.syncOnReconnect !== false,
84
+ rewindMillis: options.rewindMillis !== undefined
85
+ ? options.rewindMillis
86
+ : DefaultConfig.rewindMillis,
87
+ maxSyncPages: options.maxSyncPages || DefaultConfig.maxSyncPages,
88
+ syncPageSize: options.syncPageSize || DefaultConfig.syncPageSize,
76
89
  iceServers: options.iceServers || DefaultConfig.iceServers,
77
90
  // connect() 시 채팅방 리스트 자동 구독 여부 (카톡 스타일).
78
91
  // 기본 true — 앱 어디서든 리스트 이벤트를 놓치지 않음 (배지/알림 등).
@@ -299,7 +312,14 @@ class TalkFlowClient extends EventEmitter {
299
312
  }
300
313
 
301
314
  /**
302
- * 브라우저 locale 감지 — {@code navigator.language} 를 BCP-47 소문자로 정규화. 비브라우저/미가용 시 null.
315
+ * 브라우저 locale 감지 — {@code navigator.language} 를 BCP-47 소문자로 정규화하고 지역 변형을 primary 로
316
+ * 축약한다({@code ko-KR → 'ko'}, {@code en-US → 'en'}). 단 {@code zh} 계열은 간체/번체 구분을 위해
317
+ * 변형을 보존한다({@code zh-TW → 'zh-tw'}). 비브라우저/미가용 시 null.
318
+ *
319
+ * <p>축약 이유(값공간 통일): 서버 저장·번역·요약 키는 짧은 코드({@code 'ko'}/{@code 'en'}) 기준이다. 시드가
320
+ * 지역 변형을 그대로 보내면 명시 요청({@code lang: 'en'})과 키가 분열되어 같은 언어 이중 생성·조회 miss 가
321
+ * 발생한다. 서버 {@code LanguageCode} 도 동일 규칙으로 축약하므로 어느 쪽으로 들어와도 같은 값이 된다.</p>
322
+ *
303
323
  * <p>{@link #registerUser} 자동 시드에 사용되며, 고객사 BFF 의 {@code /users/auth} 시드에도 쓸 수 있다.</p>
304
324
  * @returns {string|null}
305
325
  */
@@ -307,7 +327,33 @@ class TalkFlowClient extends EventEmitter {
307
327
  if (typeof navigator === 'undefined' || !navigator.language) {
308
328
  return null;
309
329
  }
310
- return navigator.language.trim().toLowerCase() || null;
330
+ return TalkFlowClient.normalizeLanguageCode(navigator.language);
331
+ }
332
+
333
+ /**
334
+ * 언어 코드 canonical 정규화 — 소문자 + 지역 변형 축약(zh 계열 보존). 서버 {@code LanguageCode} 규칙의 미러.
335
+ * <p>{@code 'OFF'}(원문 sentinel)는 대문자 그대로 유지한다. 빈 값은 null.</p>
336
+ * @param {string|null|undefined} code - BCP-47 코드(예 'ko-KR') / 'OFF' / null
337
+ * @returns {string|null}
338
+ */
339
+ static normalizeLanguageCode(code) {
340
+ if (!code || typeof code !== 'string') {
341
+ return null;
342
+ }
343
+ const trimmed = code.trim();
344
+ if (!trimmed) {
345
+ return null;
346
+ }
347
+ if (trimmed.toUpperCase() === 'OFF') {
348
+ return 'OFF';
349
+ }
350
+ const lower = trimmed.toLowerCase();
351
+ const dash = lower.indexOf('-');
352
+ if (dash < 0) {
353
+ return lower;
354
+ }
355
+ const primary = lower.slice(0, dash);
356
+ return primary === 'zh' ? lower : primary;
311
357
  }
312
358
 
313
359
  /**
@@ -319,8 +365,10 @@ class TalkFlowClient extends EventEmitter {
319
365
  * @returns {string|null}
320
366
  */
321
367
  static displayText(message, myLang) {
322
- if (message && message.translations && myLang && message.translations[myLang]) {
323
- return message.translations[myLang];
368
+ // myLang 도 canonical 로 정규화 — 앱이 'ko-KR' 처럼 지역 변형을 넘겨도 서버 저장 키('ko')와 일치시킨다.
369
+ const lang = TalkFlowClient.normalizeLanguageCode(myLang);
370
+ if (message && message.translations && lang && message.translations[lang]) {
371
+ return message.translations[lang];
324
372
  }
325
373
  return message ? message.content : null;
326
374
  }
@@ -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 } from '../constants.js';
10
+ import { WebSocketPaths, LogLevel, RoomListEventType, ChatRoomType, DefaultConfig } from '../constants.js';
11
11
 
12
12
  const MAX_FILES_PER_MESSAGE = 20;
13
13
 
@@ -80,8 +80,56 @@ class ChatClient extends EventEmitter {
80
80
  // 구조: roomId → insertion-order Map<messageId, true> (LRU). size 초과 시 가장 오래된 항목 제거.
81
81
  this._seenChatMessageIdsByRoom = new Map();
82
82
  this._seenRoomListMessageIdsByRoom = new Map();
83
- // 방 하나당 최근 N개의 messageId 만 dedup 대상 — 정상 흐름에서 충돌 가능성 없는 적정 크기.
84
- this._maxSeenPerRoom = 200;
83
+
84
+ // ── 갭 복구 (RECONNECT_SYNC_DESIGN §6.2 / §6.3) ──
85
+ this.syncOnReconnect = options.syncOnReconnect !== false;
86
+ this.rewindMillis = options.rewindMillis !== undefined
87
+ ? options.rewindMillis
88
+ : DefaultConfig.rewindMillis;
89
+ this.maxSyncPages = options.maxSyncPages || DefaultConfig.maxSyncPages;
90
+ this.syncPageSize = options.syncPageSize || DefaultConfig.syncPageSize;
91
+
92
+ // 방별 서버 커서 워터마크 — Mongo _id 단독(anchor). sentAt 은 비교에 쓰지 않는다:
93
+ // 같은 메시지라도 WS(서버 systemDefault epoch)와 REST(offset 없는 ISO → 브라우저 로컬 해석)의
94
+ // sentAt 이 서로 다른 값이라, 두 출처를 섞어 비교하면 단조 규칙이 잘못된 방향으로 작동한다.
95
+ this._anchor = new Map();
96
+ // anchor 세대 — sync 진행 중 라이브 메시지가 앞서 갔는지 판정한다(generation guard).
97
+ this._anchorGen = new Map();
98
+ // 방별 sync 중복 실행 방지 (빠른 visible/hidden 토글).
99
+ this._syncInFlight = new Set();
100
+ // replay 중에는 개별 메시지로 anchor 를 전진시키지 않는다 — 완료 후 lastId 로 1회 커밋.
101
+ this._replaying = false;
102
+
103
+ // 방 하나당 dedup 대상 messageId 수. sync 상한보다 커야 되감기 구간의 중복이 새어나가지 않는다.
104
+ // 상수로 두면 maxSyncPages 튜닝 시 조용히 어긋나므로 config 에서 파생시킨다 (설계 §7).
105
+ this._maxSeenPerRoom = Math.max(200, this.maxSyncPages * this.syncPageSize + 100);
106
+
107
+ this._installSyncTriggers();
108
+ }
109
+
110
+ /**
111
+ * 갭 복구 트리거 배선 — {@code reconnected} / {@code resumed} 양쪽 (설계 §6.1 이벤트 계약).
112
+ *
113
+ * <p>두 이벤트는 상호 배타적이다: 복귀가 재연결을 유발하면 {@code reconnected} 만, 연결이 살아 있으면
114
+ * {@code resumed} 만 발행된다. 그래서 둘 다 들어도 한 번의 복귀에 sync 가 두 번 돌지 않는다.
115
+ * 반대로 하나만 들으면 절반의 시나리오를 놓친다.</p>
116
+ *
117
+ * @private
118
+ */
119
+ _installSyncTriggers() {
120
+ if (!this.connectionManager || typeof this.connectionManager.on !== 'function') return;
121
+
122
+ this._onReconnectedForSync = ({ attempts } = {}) => {
123
+ this.syncAfterReconnect(`reconnected:${attempts ?? 0}`)
124
+ .catch((error) => this.logger.warn('Gap sync after reconnect failed:', error));
125
+ };
126
+ this._onResumedForSync = ({ reason } = {}) => {
127
+ this.syncAfterReconnect(`resumed:${reason ?? 'unknown'}`)
128
+ .catch((error) => this.logger.warn('Gap sync after resume failed:', error));
129
+ };
130
+
131
+ this.connectionManager.on('reconnected', this._onReconnectedForSync);
132
+ this.connectionManager.on('resumed', this._onResumedForSync);
85
133
  }
86
134
 
87
135
  /**
@@ -145,7 +193,17 @@ class ChatClient extends EventEmitter {
145
193
  * @returns {Promise<Object>}
146
194
  */
147
195
  async getRoom(roomId) {
148
- return this.apiClient.get(`/api/v1/rooms/${roomId}`);
196
+ const genAtStart = this._anchorGen.get(roomId) || 0;
197
+ const response = await this.apiClient.get(`/api/v1/rooms/${roomId}`);
198
+
199
+ // 입장 응답의 초기 메시지(최신순)로 갭 복구 anchor 를 심는다 — 이 방에서 라이브 메시지를
200
+ // 한 건도 받기 전에 백그라운드로 가는 경로를 덮기 위함(§6.2).
201
+ // (타임스탬프 정규화는 기존 동작을 그대로 둔다 — 여기서 바꾸면 기존 소비자 계약이 흔들린다.)
202
+ const detail = this._unwrapSuccessResponse(response);
203
+ if (detail && Array.isArray(detail.messages)) {
204
+ this._seedAnchorFromLatest(roomId, detail.messages, genAtStart);
205
+ }
206
+ return response;
149
207
  }
150
208
 
151
209
  /**
@@ -925,18 +983,304 @@ class ChatClient extends EventEmitter {
925
983
  */
926
984
  async getMessages(roomId, params = {}) {
927
985
  const { size = 50, lastId, lastSortValue } = params;
986
+ // 커서가 없으면 "최신 페이지" — 이때만 anchor 를 심는다 (과거 페이지네이션은 워터마크를 뒤로 민다).
987
+ // null 과 undefined 를 함께 잡는다(느슨한 비교) — 호출자가 명시적으로 null 을 넘겨도 서버에는
988
+ // 최신 페이지로 나가므로, 엄격 비교로 두면 그 경로만 조용히 seed 를 건너뛴다.
989
+ const isLatestPage = lastId == null && lastSortValue == null;
990
+ const genAtStart = this._anchorGen.get(roomId) || 0;
991
+
928
992
  const response = await this.apiClient.get(
929
993
  `/api/v1/chat-messages/${roomId}/list`, { size, lastId, lastSortValue }
930
994
  );
931
995
  // REST 와 WebSocket 의 타임스탬프 직렬화가 다르다 (REST=ISO 문자열, WS=epoch number).
932
996
  // 선언된 타입(number)으로 통일 — 기존 응답 구조(envelope)는 그대로 둔다.
933
997
  const page = this._unwrapSuccessResponse(response);
998
+ if (page && Array.isArray(page.content)) {
999
+ page.content.forEach((m) => this._normalizeMessageTimestamps(m));
1000
+ if (isLatestPage) {
1001
+ this._seedAnchorFromLatest(roomId, page.content, genAtStart);
1002
+ }
1003
+ }
1004
+ return response;
1005
+ }
1006
+
1007
+ /**
1008
+ * 초기 REST 로드로 anchor 를 심는다 (RECONNECT_SYNC_DESIGN §6.2).
1009
+ *
1010
+ * <p><b>없으면 가장 흔한 복귀 경로가 통째로 skip 된다</b> — 방에 들어와 초기 50건을 REST 로 본 뒤
1011
+ * 라이브 메시지를 한 건도 받기 전에 백그라운드로 가면 anchor 가 없어 갭 복구가 돌지 않는다.
1012
+ * 그게 바로 이 기능이 없애려는 증상이다.</p>
1013
+ *
1014
+ * <p><b>최신 페이지에서만</b> 호출해야 한다. 과거 방향 페이지네이션(무한 스크롤) 결과로 심으면
1015
+ * 워터마크가 뒤로 밀린다. 목록은 최신순(DESC)이라 {@code content[0]} 이 가장 최신이다.</p>
1016
+ *
1017
+ * <p>진행 중 라이브 메시지가 도착했으면(세대 변화) 그쪽이 더 최신이므로 덮어쓰지 않는다 —
1018
+ * {@code id} 는 문자열 비교로 시간 순서를 보장하지 못해 "더 최신인지" 를 직접 판별할 수 없기 때문에
1019
+ * sync 커밋과 동일한 generation guard 로 처리한다.</p>
1020
+ *
1021
+ * @private
1022
+ */
1023
+ _seedAnchorFromLatest(roomId, descendingMessages, genAtStart) {
1024
+ if (!Array.isArray(descendingMessages) || descendingMessages.length === 0) return false;
1025
+ const newest = descendingMessages[0];
1026
+ if (!newest || !newest.id) return false;
1027
+ return this._commitAnchorFromSync(roomId, newest.id, genAtStart);
1028
+ }
1029
+
1030
+ /**
1031
+ * 갭 복구용 forward 조회 — 앵커 <b>이후</b> 메시지를 오름차순으로 가져온다.
1032
+ *
1033
+ * <p>커서는 메시지 {@code id}(Mongo {@code _id}) 하나뿐이다. 시간값은 보내지 않는다 —
1034
+ * 서버가 앵커를 조회해 {@code sentAt} 을 직접 읽으므로 timezone/정밀도 불일치가 생길 수 없다.</p>
1035
+ *
1036
+ * <p>읽음 부작용이 없다({@code /list} 와 동일). 백그라운드 방의 unread 를 건드리지 않는다.</p>
1037
+ *
1038
+ * @param {string} roomId
1039
+ * @param {Object} [params]
1040
+ * @param {string} [params.afterId] - 앵커 메시지의 Mongo _id. 없으면 방의 가장 오래된 것부터.
1041
+ * @param {number} [params.rewindMillis] - 시계 스큐 방어 되감기. <b>첫 페이지에만</b> 보낸다.
1042
+ * @param {number} [params.size]
1043
+ * @returns {Promise<Object>}
1044
+ */
1045
+ async syncMessages(roomId, params = {}) {
1046
+ const { afterId, rewindMillis, size = this.syncPageSize } = params;
1047
+ const response = await this.apiClient.get(
1048
+ `/api/v1/chat-messages/${roomId}/sync`, { afterId, rewindMillis, size }
1049
+ );
1050
+ const page = this._unwrapSuccessResponse(response);
934
1051
  if (page && Array.isArray(page.content)) {
935
1052
  page.content.forEach((m) => this._normalizeMessageTimestamps(m));
936
1053
  }
937
1054
  return response;
938
1055
  }
939
1056
 
1057
+ /**
1058
+ * 라이브 수신으로 anchor 전진 (+세대 증가).
1059
+ *
1060
+ * <p>{@code MESSAGE_CREATED} 에서만 호출한다. UPDATED/DELETED/REACTION 은 과거 메시지의 재발행이라
1061
+ * anchor 를 건드리면 워터마크가 뒤로 밀린다.</p>
1062
+ *
1063
+ * @private
1064
+ */
1065
+ _advanceAnchorFromLive(roomId, messageId) {
1066
+ if (!roomId || !messageId) return;
1067
+ this._anchor.set(roomId, messageId);
1068
+ this._anchorGen.set(roomId, (this._anchorGen.get(roomId) || 0) + 1);
1069
+ }
1070
+
1071
+ /**
1072
+ * sync 완료 커밋 — 시작 시점 세대가 유지된 경우에만 anchor 를 덮어쓴다.
1073
+ *
1074
+ * <p>sync 진행 중 라이브 메시지가 들어와 anchor 가 더 앞서 갔을 수 있는데, 그걸 sync 결과로 덮으면
1075
+ * <b>워터마크가 후퇴</b>해 다음 sync 가 같은 구간을 다시 긁는다.</p>
1076
+ *
1077
+ * @private
1078
+ * @returns {boolean} 커밋 여부
1079
+ */
1080
+ _commitAnchorFromSync(roomId, lastId, genAtStart) {
1081
+ if (!lastId) return false;
1082
+ if ((this._anchorGen.get(roomId) || 0) !== genAtStart) {
1083
+ this.logger.debug(`Anchor commit skipped (live advanced): room=${roomId}`);
1084
+ return false;
1085
+ }
1086
+ this._anchor.set(roomId, lastId);
1087
+ return true;
1088
+ }
1089
+
1090
+ /**
1091
+ * 복구한 메시지를 라이브와 <b>동일한 경로</b>로 재생한다.
1092
+ *
1093
+ * <p>반드시 {@link #_handleChatMessage} 하나로만 흘려야 한다 — dedup({@code _shouldDedupMessage})은
1094
+ * check 와 record 가 한 몸이라, 호출자가 미리 dedup 을 돌린 뒤 넘기면 {@code _handleChatMessage} 가
1095
+ * 같은 messageId 를 중복으로 판정해 <b>복구 메시지를 전부 삼킨다</b>.</p>
1096
+ *
1097
+ * <p>REST 응답에는 {@code eventType} 이 없으므로 {@code MESSAGE_CREATED} 를 합성한다. 삭제
1098
+ * placeholder 도 CREATED 로 흘린다 — dedup 대상이 CREATED 뿐이라, DELETED 로 보내면 재동기화 때마다
1099
+ * 중복 emit 된다. 삭제 표시는 payload 의 {@code deletedStatus} 가 담당한다.</p>
1100
+ *
1101
+ * @private
1102
+ */
1103
+ _replayMessages(roomId, messages) {
1104
+ if (!Array.isArray(messages) || messages.length === 0) return 0;
1105
+
1106
+ let emitted = 0;
1107
+ this._replaying = true;
1108
+ try {
1109
+ messages.forEach((message) => {
1110
+ // 되감기 구간에는 이미 받은 메시지가 정상적으로 섞여 들어온다. dedup 에 걸려 화면에
1111
+ // 나가지 않은 건 복구 건수에서 빼야 syncCompleted 지표가 "실제로 새로 들어온 수" 가 된다.
1112
+ const handled = this._handleChatMessage(roomId, {
1113
+ ...message,
1114
+ eventType: message.eventType || 'MESSAGE_CREATED'
1115
+ });
1116
+ if (handled) emitted += 1;
1117
+ });
1118
+ } finally {
1119
+ this._replaying = false;
1120
+ }
1121
+ return emitted;
1122
+ }
1123
+
1124
+ /**
1125
+ * 갭이 상한을 넘었을 때의 폴백 — 최신 페이지만 다시 싣는다.
1126
+ *
1127
+ * <p>{@code getRoom}/{@code enterRoom} 은 서버 {@code markAllAsRead} 를 트리거하므로 절대 쓰지 않는다.
1128
+ * 백그라운드 방의 누적 unread 가 통째로 지워진다. 읽음 부작용이 없는 {@code /list} 만 사용한다.</p>
1129
+ *
1130
+ * @private
1131
+ */
1132
+ async _reloadLatestPage(roomId, genAtStart) {
1133
+ const response = await this.getMessages(roomId, { size: this.syncPageSize });
1134
+ const page = this._unwrapSuccessResponse(response) || {};
1135
+ const content = Array.isArray(page.content) ? page.content : [];
1136
+
1137
+ // /list 는 최신순(DESC) — 도착 순서대로 replay 하려면 뒤집는다.
1138
+ const ascending = content.slice().reverse();
1139
+ // 폴백 페이지에도 이미 본 메시지가 섞이므로, 지표는 조회 건수가 아니라 실제 emit 수여야 한다.
1140
+ const emitted = this._replayMessages(roomId, ascending);
1141
+
1142
+ const newest = ascending.length ? ascending[ascending.length - 1].id : null;
1143
+ this._commitAnchorFromSync(roomId, newest, genAtStart);
1144
+ return emitted;
1145
+ }
1146
+
1147
+ /** @private */
1148
+ _isCursorExpired(error) {
1149
+ return error?.status === 410 || error?.response?.code === 'SYNC_CURSOR_EXPIRED';
1150
+ }
1151
+
1152
+ /**
1153
+ * 방 하나의 갭 복구 (RECONNECT_SYNC_DESIGN §6.3).
1154
+ *
1155
+ * <p><b>fetch 와 replay 를 분리</b>한다. 페이지마다 즉시 emit 하면 상한 초과를 알게 된 시점에 이미
1156
+ * 일부를 내보낸 뒤라 "부분 replay 없음" 이 성립하지 않는다.</p>
1157
+ *
1158
+ * @private
1159
+ * @returns {Promise<Object|null>} 복구 결과. 대상이 아니면 null.
1160
+ */
1161
+ async _syncRoom(roomId) {
1162
+ const anchor = this._anchor.get(roomId);
1163
+ // 이 세션에서 아직 받은 메시지가 없는 방 — enterRoom 의 초기 로드가 담당한다.
1164
+ if (!anchor) return null;
1165
+ if (this._syncInFlight.has(roomId)) return null;
1166
+
1167
+ this._syncInFlight.add(roomId);
1168
+ const genAtStart = this._anchorGen.get(roomId) || 0;
1169
+ const startedAt = Date.now();
1170
+
1171
+ try {
1172
+ // ── Phase 1: fetch only (emit 없음) ──
1173
+ const buffer = [];
1174
+ let cursor = anchor;
1175
+ // 되감기는 워터마크 기준 첫 페이지에서 1회만 의미가 있다. 매 페이지 되감으면 같은 구간을 반복 조회한다.
1176
+ let rewindMillis = this.rewindMillis;
1177
+ let hasMore = true;
1178
+ let pages = 0;
1179
+
1180
+ while (hasMore && pages < this.maxSyncPages) {
1181
+ const response = await this.syncMessages(roomId, { afterId: cursor, rewindMillis });
1182
+ const page = this._unwrapSuccessResponse(response) || {};
1183
+ const content = Array.isArray(page.content) ? page.content : [];
1184
+
1185
+ buffer.push(...content);
1186
+ rewindMillis = 0;
1187
+ hasMore = !!page.hasNext;
1188
+ if (page.lastId) cursor = page.lastId;
1189
+ pages += 1;
1190
+ }
1191
+
1192
+ // ── Phase 2: 판정 후 replay ──
1193
+ if (hasMore) {
1194
+ // 상한 초과 — 버퍼를 통째로 버리고 최신 페이지로 전환한다(부분 replay 금지).
1195
+ this.logger.info(`Gap too large, falling back to latest page: room=${roomId}`);
1196
+ this.emit('gapTruncated', { roomId, fetched: buffer.length });
1197
+ const reloaded = await this._reloadLatestPage(roomId, genAtStart);
1198
+ return {
1199
+ roomId, recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt
1200
+ };
1201
+ }
1202
+
1203
+ const recovered = this._replayMessages(roomId, buffer);
1204
+ this._commitAnchorFromSync(roomId, cursor, genAtStart);
1205
+ return { roomId, recovered, truncated: false, durationMs: Date.now() - startedAt };
1206
+
1207
+ } catch (error) {
1208
+ if (this._isCursorExpired(error)) {
1209
+ // retention 정리 등으로 앵커가 사라짐 — 최신 페이지로 복구한다.
1210
+ this.logger.warn(`Sync cursor expired, reloading latest: room=${roomId}`);
1211
+ this.emit('gapTruncated', { roomId, fetched: 0, reason: 'cursorExpired' });
1212
+ const reloaded = await this._reloadLatestPage(roomId, genAtStart);
1213
+ return {
1214
+ roomId, recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt
1215
+ };
1216
+ }
1217
+ throw error;
1218
+ } finally {
1219
+ this._syncInFlight.delete(roomId);
1220
+ }
1221
+ }
1222
+
1223
+ /**
1224
+ * 구독 중인 모든 방의 갭 복구 + 방 목록 갱신.
1225
+ *
1226
+ * <p>재연결 성공({@code reconnected}) 과 복귀 감지({@code resumed}) 양쪽에서 호출된다. 두 이벤트는
1227
+ * 상호 배타적이라 중복 실행되지 않는다.</p>
1228
+ *
1229
+ * @param {string} [reason]
1230
+ * @returns {Promise<void>}
1231
+ */
1232
+ async syncAfterReconnect(reason = 'manual') {
1233
+ if (!this.syncOnReconnect) return;
1234
+
1235
+ const roomIds = Array.from(this.subscribedRooms.keys()).filter((id) => this._anchor.has(id));
1236
+ if (roomIds.length === 0) {
1237
+ await this._refreshRoomList(reason);
1238
+ return;
1239
+ }
1240
+
1241
+ this.emit('syncStarted', { roomIds, reason });
1242
+ const startedAt = Date.now();
1243
+ const rooms = [];
1244
+
1245
+ for (const roomId of roomIds) {
1246
+ try {
1247
+ const result = await this._syncRoom(roomId);
1248
+ if (result) rooms.push(result);
1249
+ } catch (error) {
1250
+ // 한 방의 실패가 나머지 방 복구를 막지 않게 한다.
1251
+ this.logger.warn(`Gap sync failed: room=${roomId}`, error);
1252
+ }
1253
+ }
1254
+
1255
+ await this._refreshRoomList(reason);
1256
+
1257
+ this.emit('syncCompleted', {
1258
+ rooms,
1259
+ totalRecovered: rooms.reduce((sum, r) => sum + (r.recovered || 0), 0),
1260
+ durationMs: Date.now() - startedAt,
1261
+ reason
1262
+ });
1263
+ }
1264
+
1265
+ /**
1266
+ * 재연결 시 방 목록/unread 1회 갱신.
1267
+ *
1268
+ * <p>기존 {@code roomListUpdate} 로 흘리지 않는다 — 그건 단건 delta 계약이라 전체 스냅샷을 실으면
1269
+ * 기존 구독자가 형태가 다른 payload 를 delta 로 오해한다. 별도 {@code roomListSynced} 로 분리.</p>
1270
+ *
1271
+ * @private
1272
+ */
1273
+ async _refreshRoomList(reason) {
1274
+ if (!this.roomListSubscribed) return;
1275
+ try {
1276
+ const response = await this.getRooms({ size: 50 });
1277
+ const page = this._unwrapSuccessResponse(response) || {};
1278
+ this.emit('roomListSynced', { rooms: page.content || [], reason });
1279
+ } catch (error) {
1280
+ this.logger.warn('Room list refresh failed (non-fatal):', error);
1281
+ }
1282
+ }
1283
+
940
1284
  /**
941
1285
  * 링크 미리보기 조회.
942
1286
  *
@@ -1941,7 +2285,13 @@ class ChatClient extends EventEmitter {
1941
2285
  if (message.eventType === 'MESSAGE_CREATED'
1942
2286
  && this._shouldDedupMessage(this._seenChatMessageIdsByRoom, roomId, message.messageId)) {
1943
2287
  this.logger.debug(`Duplicate MESSAGE_CREATED suppressed: room=${roomId}, messageId=${message.messageId}`);
1944
- return;
2288
+ return false;
2289
+ }
2290
+
2291
+ // 갭 복구 워터마크 전진 — 라이브 수신분만. replay 중에는 개별 전진을 막고
2292
+ // sync 완료 시 응답 lastId 로 1회 커밋한다(중간 실패 시 어중간한 위치에 남는 것 방지).
2293
+ if (message.eventType === 'MESSAGE_CREATED' && !this._replaying) {
2294
+ this._advanceAnchorFromLive(roomId, message.id);
1945
2295
  }
1946
2296
 
1947
2297
  // 편집(MESSAGE_UPDATED) 시 서버가 번역을 무효화한다. payload 에 번역 필드가 없으면 generic emit 前에
@@ -2021,6 +2371,8 @@ class ChatClient extends EventEmitter {
2021
2371
  }
2022
2372
  }
2023
2373
  }
2374
+
2375
+ return true;
2024
2376
  }
2025
2377
 
2026
2378
  /**
@@ -2496,6 +2848,20 @@ class ChatClient extends EventEmitter {
2496
2848
  this._seenChatMessageIdsByRoom.clear();
2497
2849
  this._seenRoomListMessageIdsByRoom.clear();
2498
2850
 
2851
+ // 갭 복구 트리거 해제 — connectionManager 는 ChatClient 보다 오래 살 수 있어(사용자 전환 등)
2852
+ // 리스너를 남기면 죽은 ChatClient 가 계속 깨어난다.
2853
+ if (this.connectionManager && typeof this.connectionManager.off === 'function') {
2854
+ if (this._onReconnectedForSync) {
2855
+ this.connectionManager.off('reconnected', this._onReconnectedForSync);
2856
+ }
2857
+ if (this._onResumedForSync) {
2858
+ this.connectionManager.off('resumed', this._onResumedForSync);
2859
+ }
2860
+ }
2861
+ this._anchor.clear();
2862
+ this._anchorGen.clear();
2863
+ this._syncInFlight.clear();
2864
+
2499
2865
  this.removeAllListeners();
2500
2866
  this.logger.info('ChatClient destroyed');
2501
2867
  }
package/src/constants.js CHANGED
@@ -376,10 +376,23 @@ export const DefaultConfig = {
376
376
 
377
377
  // Connection
378
378
  reconnectDelay: 5000,
379
+ // 지수 백오프 상한 — 서버 재시작 시 전 클라이언트가 고정 간격으로 몰리는 것을 완화.
380
+ maxReconnectDelay: 30000,
379
381
  maxReconnectAttempts: 10,
380
382
  heartbeatIncoming: 10000,
381
383
  heartbeatOutgoing: 10000,
382
384
 
385
+ // 포그라운드 복귀 / 네트워크 복구 시 자동 재연결 (RECONNECT_SYNC_DESIGN §6.1). false = 킬 스위치.
386
+ resumeOnVisibility: true,
387
+
388
+ // 갭 복구 (RECONNECT_SYNC_DESIGN §6.3). false = 킬 스위치.
389
+ syncOnReconnect: true,
390
+ // 멀티 인스턴스 시계 스큐 방어 되감기 폭 — 첫 페이지에만, 서버가 clamp 후 1회 차감.
391
+ rewindMillis: 3000,
392
+ // 갭 페이지 상한. 초과 시 버퍼 폐기 + gapTruncated + 최신 페이지 재로드.
393
+ maxSyncPages: 5,
394
+ syncPageSize: 100,
395
+
383
396
  // API
384
397
  apiTimeout: 30000,
385
398