@vibexnpm/talkx 8.5.0 → 8.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibexnpm/talkx",
3
- "version": "8.5.0",
3
+ "version": "8.7.0",
4
4
  "type": "module",
5
5
  "description": "TalkFlow SDK - Chat & WebRTC unified client",
6
6
  "main": "dist/talkflow-sdk.umd.js",
@@ -39,6 +39,7 @@ class TalkFlowClient extends EventEmitter {
39
39
  * // 1단계: 로그인 — 클라이언트 생성 «전» 에 부른다. ck/projectId/JWT 를 한 번에 받는다.
40
40
  * // const { clientKey, projectId, accessToken } = await TalkFlowClient.loginUser({
41
41
  * // serverUrl, email, password });
42
+ * // (회사 계정이 둘 이상이면 먼저 workspaceSelectionRequired 결과 → projectId 를 실어 다시 부른다)
42
43
  *
43
44
  * // 2단계: 받은 값으로 SDK 초기화
44
45
  * const client = new TalkFlowClient({
@@ -72,6 +73,7 @@ class TalkFlowClient extends EventEmitter {
72
73
  reconnectDelay: options.reconnectDelay || DefaultConfig.reconnectDelay,
73
74
  maxReconnectDelay: options.maxReconnectDelay || DefaultConfig.maxReconnectDelay,
74
75
  maxReconnectAttempts: options.maxReconnectAttempts || DefaultConfig.maxReconnectAttempts,
76
+ reconnectRecoveryDelay: options.reconnectRecoveryDelay ?? DefaultConfig.reconnectRecoveryDelay,
75
77
  heartbeatIncoming: options.heartbeatIncoming || DefaultConfig.heartbeatIncoming,
76
78
  heartbeatOutgoing: options.heartbeatOutgoing || DefaultConfig.heartbeatOutgoing,
77
79
  // 탭 복귀 / 네트워크 복구 시 자동 재연결. false 로 꺼야 하므로 !== false 로 보존 —
@@ -309,21 +311,28 @@ class TalkFlowClient extends EventEmitter {
309
311
  * <p>Server Key 도 고객사 BFF 도 필요 없다. 응답의
310
312
  * {@code clientKey}/{@code projectId}/{@code accessToken} 이 그대로 SDK 초기화 입력이다.</p>
311
313
  *
312
- * <p><b>회사 선택 단계가 없다</b>: 로그인 키가 전역 유일이라 이메일 하나로 회원과 회사가
313
- * 함께 정해진다. 퇴사하면 그 키가 풀려 다른 회사에서 같은 이메일을 다시 쓸 수 있다.</p>
314
+ * <p><b>회사 선택</b>: 같은 이메일이 여러 회사에 계정을 가질 수 있다(다른 회사의 게스트 등). 비밀번호가 맞는 계정이
315
+ * 둘 이상이면 토큰 없이 {@code {workspaceSelectionRequired: true, workspaces: [{projectId, companyName, companyRole}]}}
316
+ * 를 돌려준다 — 저장된 세션은 그대로다. 고른 {@code projectId} 를 실어 같은 이메일·비밀번호로 다시 부른다.
317
+ * 계정이 하나면 지금처럼 바로 로그인된다.</p>
314
318
  *
315
319
  * @param {Object} options
316
320
  * @param {string} options.serverUrl - 채팅 서버 base URL
317
321
  * @param {string} options.email
318
322
  * @param {string} options.password
319
- * @returns {Promise<Object>} `{accessToken, tokenType, expiresIn, projectId, clientKey, user}`
323
+ * @param {string} [options.projectId] - 회사 선택 응답에서 고른 회사
324
+ * @returns {Promise<Object>} `{accessToken, tokenType, expiresIn, projectId, clientKey, user}` 또는 회사 선택 결과
320
325
  *
321
326
  * @example
322
- * const login = await TalkFlowClient.loginUser({
327
+ * let login = await TalkFlowClient.loginUser({
323
328
  * serverUrl: 'https://chat.apiorbit.net',
324
329
  * email: 'dan@example.com',
325
330
  * password: '********'
326
331
  * });
332
+ * if (login.workspaceSelectionRequired) {
333
+ * const picked = await showWorkspacePicker(login.workspaces); // 화면에서 고른 회사
334
+ * login = await TalkFlowClient.loginUser({ serverUrl, email, password, projectId: picked.projectId });
335
+ * }
327
336
  *
328
337
  * const client = new TalkFlowClient({
329
338
  * apiKey: login.clientKey,
@@ -332,13 +341,13 @@ class TalkFlowClient extends EventEmitter {
332
341
  * });
333
342
  * await client.connect();
334
343
  */
335
- static async loginUser({ serverUrl, email, password }) {
344
+ static async loginUser({ serverUrl, email, password, projectId }) {
336
345
  if (!serverUrl) throw new Error('serverUrl is required');
337
346
  if (!email) throw new Error('email is required');
338
347
  if (!password) throw new Error('password is required');
339
348
 
340
349
  // 로그인은 키를 «받기 위해» 부르는 경로라 X-API-KEY/X-PROJECT-ID 가 없다(서버에서 필터 제외).
341
- return AuthSession.forServer(serverUrl).login({ email, password });
350
+ return AuthSession.forServer(serverUrl).login({ email, password, projectId });
342
351
  }
343
352
 
344
353
  /**
@@ -347,16 +356,19 @@ class TalkFlowClient extends EventEmitter {
347
356
  * <p>이미 클라이언트가 있는 상태의 재로그인·계정 전환용이다. 최초 진입은 클라이언트 생성 전에
348
357
  * 부르는 정적 {@link TalkFlowClient.loginUser} 를 쓴다 — 그쪽이 {@code clientKey} 까지 준다.</p>
349
358
  *
359
+ * <p>회사 선택 응답이면 토큰을 설정하지 않고 그 결과를 돌려준다 — 정적 {@link TalkFlowClient.loginUser} 와 같다.</p>
360
+ *
350
361
  * @param {Object} credentials
351
362
  * @param {string} credentials.email
352
363
  * @param {string} credentials.password
353
- * @returns {Promise<Object>} 로그인 응답
364
+ * @param {string} [credentials.projectId] - 회사 선택 응답에서 고른 회사
365
+ * @returns {Promise<Object>} 로그인 응답 또는 회사 선택 결과
354
366
  */
355
- async loginUser({ email, password }) {
367
+ async loginUser({ email, password, projectId }) {
356
368
  if (!email || !password) throw new Error('email and password are required');
357
369
  this._enableRefreshSession();
358
370
  await this._authReady;
359
- return this.authSession.login({ email, password });
371
+ return this.authSession.login({ email, password, projectId });
360
372
  }
361
373
 
362
374
  /**
@@ -4,6 +4,21 @@ const sessions = new Map();
4
4
  const TERMINAL_CODES = new Set(['REFRESH_INVALID', 'REFRESH_REUSED', 'SESSION_REVOKED', 'ACCOUNT_BLOCKED', 'LOGIN_FAILED']);
5
5
  export const isTerminalAuthError = error => TERMINAL_CODES.has(error?.response?.code || error?.code);
6
6
 
7
+ /**
8
+ * 로그인 회사 선택 결과 — 토큰이 없다. 목록이 비었거나 회사 id 가 없는 응답은 계약 위반이라 인증 불가로 끊는다(빈 선택 화면을 띄우지 않게).
9
+ */
10
+ function workspaceSelection(data) {
11
+ const workspaces = Array.isArray(data.workspaces) ? data.workspaces : [];
12
+ if (workspaces.length === 0 || workspaces.some(workspace => !workspace?.projectId)) {
13
+ const error = new Error('Invalid authentication response'); error.code = 'AUTH_UNAVAILABLE'; throw error;
14
+ }
15
+ return {
16
+ workspaceSelectionRequired: true,
17
+ workspaces: workspaces.map(({ projectId, companyName, companyRole }) =>
18
+ ({ projectId, companyName: companyName ?? null, companyRole }))
19
+ };
20
+ }
21
+
7
22
  export function tokenClaims(token) {
8
23
  try {
9
24
  const value = token.replace(/^Bearer\s+/i, '').split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
@@ -240,13 +255,23 @@ export default class AuthSession extends EventEmitter {
240
255
  } finally { clearTimeout(timeout); }
241
256
  }
242
257
 
243
- async login({ email, password }) {
258
+ /**
259
+ * 같은 이메일로 여러 회사 계정이 있고 비밀번호가 둘 이상 맞으면 서버는 토큰 없이 회사 목록만 준다. 그 응답은
260
+ * {@link workspaceSelection} 결과로 돌려준다. 저장소·인증 상태·이전 세션은 건드리지 않는다 — 서버도 이 단계에서는
261
+ * 이전 세션을 끊지 않는다. 호출자는 고른 {@code projectId} 를 실어 같은 자격으로 다시 부른다(서버가 비밀번호를 다시 검증).
262
+ */
263
+ async login({ email, password, projectId }) {
244
264
  const generation = ++this.generation;
245
265
  return this._locked(async () => {
246
266
  const previous = this._readMarker();
247
267
  this._writeMarker(previous);
248
268
  if (previous.pendingRefreshToken) await this._request('/v1/auth/signout', { refreshToken: previous.pendingRefreshToken, deviceId: previous.deviceId });
249
- const data = await this._request('/api/v1/auth/login', { email, password, previousRefreshToken: previous.refreshToken });
269
+ const data = await this._request('/api/v1/auth/login',
270
+ { email, password, previousRefreshToken: previous.refreshToken, ...(projectId ? { projectId } : {}) });
271
+ if (data?.workspaceSelectionRequired === true) {
272
+ if (generation !== this.generation) throw this._cancelled();
273
+ return workspaceSelection(data);
274
+ }
250
275
  if (!data?.refreshToken) {
251
276
  const error = new Error('Server does not support the refresh session contract');
252
277
  error.code = 'AUTH_PROTOCOL_UNSUPPORTED';
@@ -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
  /**
@@ -2479,8 +2511,8 @@ class ChatClient extends EventEmitter {
2479
2511
  * @param {string} roomId - 채팅방 ID
2480
2512
  * @returns {Promise<void>}
2481
2513
  *
2482
- * @fires memberJoined - { roomId, members: [{ userId, nickname, profileUrl }], participantCount, timestamp }
2483
- * @fires memberLeft - { roomId, members: [{ userId, nickname, profileUrl }], participantCount, timestamp }
2514
+ * @fires memberJoined - { roomId, members: [{ userId, nickname, profileUrl, companyRole? }], participantCount, timestamp }
2515
+ * @fires memberLeft - { roomId, members: [{ userId, nickname, profileUrl, companyRole? }], participantCount, timestamp }
2484
2516
  */
2485
2517
  async subscribeRoom(roomId) {
2486
2518
  if (this.subscribedRooms.has(roomId)) {
@@ -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