@vibexnpm/talkx 7.2.0 → 8.0.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.
@@ -3,6 +3,7 @@
3
3
  * WebSocket/STOMP 연결 관리 (Chat, WebRTC 공유)
4
4
  */
5
5
 
6
+ import { isTerminalAuthError } from '../auth/AuthSession.js';
6
7
  import EventEmitter from '../utils/EventEmitter.js';
7
8
  import Logger from '../utils/Logger.js';
8
9
  import { ConnectionState, ErrorTypes, WebSocketPaths, DefaultConfig, LogLevel } from '../constants.js';
@@ -289,6 +290,36 @@ class ConnectionManager extends EventEmitter {
289
290
  const wsUrl = this._getWebSocketUrl();
290
291
 
291
292
  const config = {
293
+ beforeConnect: async (client) => {
294
+ if (!this.getAccessToken) return;
295
+ let attempt = 0;
296
+ while (!this._intentionalDisconnect && (!client || client.active)) {
297
+ try {
298
+ const token = await this.getAccessToken({ forceRefresh: this._authRejected === true });
299
+ if (this._intentionalDisconnect || (client && !client.active)) return;
300
+ this.updateToken(token);
301
+ this._authRejected = false;
302
+ return;
303
+ } catch (error) {
304
+ this.emit('error', { type: ErrorTypes.CONNECTION_FAILED, message: error.message, error });
305
+ if (isTerminalAuthError(error)) {
306
+ this._setState(ConnectionState.ERROR);
307
+ this._connectReject?.(error);
308
+ this._connectResolve = this._connectReject = null;
309
+ await (client || this.stompClient)?.deactivate();
310
+ return;
311
+ }
312
+ // beforeConnect의 예외에는 STOMP 소켓 종료 백오프가 적용되지 않는다.
313
+ const wait = Math.min(30000, 1000 * 2 ** Math.min(attempt++, 5)) * (0.8 + Math.random() * 0.4);
314
+ await new Promise(resolve => {
315
+ this._cancelAuthRetry = () => { clearTimeout(this._authRetryTimer); resolve(); };
316
+ this._authRetryTimer = setTimeout(resolve, wait);
317
+ this._authRetryTimer.unref?.();
318
+ });
319
+ this._cancelAuthRetry = null;
320
+ }
321
+ }
322
+ },
292
323
  connectHeaders: {
293
324
  'Authorization': this.jwtToken.startsWith('Bearer ')
294
325
  ? this.jwtToken
@@ -369,6 +400,7 @@ class ConnectionManager extends EventEmitter {
369
400
  * @private
370
401
  */
371
402
  _onConnect(frame) {
403
+ this._authenticatedToken = this.jwtToken;
372
404
  // 재연결 여부 판정은 카운터 리셋 전에 — 갭 복구(S4)가 이 신호로 동기화를 건다.
373
405
  const attempts = this.reconnectAttempts;
374
406
  const isReconnect = attempts > 0 || this._hasConnectedBefore === true;
@@ -413,6 +445,10 @@ class ConnectionManager extends EventEmitter {
413
445
  * @private
414
446
  */
415
447
  _onStompError(frame) {
448
+ try {
449
+ const payload = JSON.parse(frame.body || frame.headers?.message || '{}');
450
+ this._authRejected = ['ACCESS_UNAUTHORIZED', 'SESSION_REVOKED', 'TOKEN_INVALID'].includes(payload.code);
451
+ } catch { /* Non-authentication STOMP errors keep the normal reconnect policy. */ }
416
452
  this.logger.error('STOMP error:', frame);
417
453
  this._setState(ConnectionState.ERROR);
418
454
 
@@ -439,6 +475,8 @@ class ConnectionManager extends EventEmitter {
439
475
  * @private
440
476
  */
441
477
  _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;
442
480
  this.logger.warn('WebSocket closed:', event);
443
481
 
444
482
  // forceReconnect 의 의도적 teardown 이면 재연결 시도로 집계하지 않는다 —
@@ -657,6 +695,10 @@ class ConnectionManager extends EventEmitter {
657
695
  async disconnect() {
658
696
  // 명시적 해제 — 포그라운드 복귀가 이걸 되살리면 안 된다.
659
697
  this._intentionalDisconnect = true;
698
+ this._connectRequested = false;
699
+ this._cancelAuthRetry?.();
700
+ this._connectReject?.(Object.assign(new Error('Connection cancelled'), { code: 'AUTH_OPERATION_CANCELLED' }));
701
+ this._connectResolve = this._connectReject = null;
660
702
  this._drainPendingSubscriptions('Disconnected before subscription completed');
661
703
 
662
704
  if (this.stompClient) {
@@ -537,6 +537,7 @@ class PushManager {
537
537
  async _registerTokenToServer(token) {
538
538
  try {
539
539
  const deviceId = await this._getDeviceId();
540
+ this.apiClient?.authProvider?.setDeviceId(deviceId);
540
541
  await this.apiClient.post('/api/v1/push/devices', {
541
542
  deviceToken: token,
542
543
  deviceType: 'WEB',
@@ -623,6 +624,7 @@ class PushManager {
623
624
  }
624
625
 
625
626
  this._deviceIdCache = deviceId;
627
+ this.apiClient?.authProvider?.setDeviceId(deviceId);
626
628
  return deviceId;
627
629
  } catch (error) {
628
630
  // IndexedDB 장애 (open / read / transaction throw 등) 시 fallback.
@@ -27,6 +27,10 @@ export function initializeSubClients(client) {
27
27
  resumeOnVisibility: client.options.resumeOnVisibility,
28
28
  logLevel: client.options.logLevel
29
29
  });
30
+ client.connectionManager.getAccessToken = async ({ forceRefresh = false } = {}) => {
31
+ if (!client.authSession) return client.options.jwtToken;
32
+ return client.authSession.getAccessToken({ forceRefresh, rejectedToken: client.connectionManager?.jwtToken });
33
+ };
30
34
 
31
35
  client._chat = new ChatClient({
32
36
  connectionManager: client.connectionManager,
@@ -60,6 +64,14 @@ export function applySessionMethods(TalkFlowClient) {
60
64
  await this.setToken(jwt);
61
65
  }
62
66
 
67
+ if (this.authSession) {
68
+ await this._authReady;
69
+ this._authConnectionRequested = true;
70
+ const token = await this.authSession.getAccessToken();
71
+ await this.setToken(token);
72
+ this.authSession.schedule();
73
+ }
74
+
63
75
  if (!this.options.jwtToken) {
64
76
  throw new Error(
65
77
  'JWT token is required. Sign in with TalkFlowClient.loginUser({ serverUrl, ' +
@@ -68,7 +80,7 @@ export function applySessionMethods(TalkFlowClient) {
68
80
  );
69
81
  }
70
82
 
71
- if (isJWTExpired(this.options.jwtToken)) {
83
+ if (!this.authSession && isJWTExpired(this.options.jwtToken)) {
72
84
  throw new Error('JWT token has expired. Please update the token before connecting.');
73
85
  }
74
86
 
@@ -98,6 +110,8 @@ export function applySessionMethods(TalkFlowClient) {
98
110
  };
99
111
 
100
112
  TalkFlowClient.prototype.disconnect = async function disconnect() {
113
+ this._authConnectionRequested = false;
114
+ this.authSession?.schedule();
101
115
  if (!this.connectionManager) {
102
116
  return;
103
117
  }
@@ -120,6 +134,7 @@ export function applySessionMethods(TalkFlowClient) {
120
134
  };
121
135
 
122
136
  TalkFlowClient.prototype.logout = async function logout() {
137
+ if (this.authSession) return this.authSession.logout();
123
138
  try {
124
139
  await this.apiClient.post('/v1/auth/signout');
125
140
  this.logger.info('Logged out from server');
@@ -339,6 +354,8 @@ export function applySessionMethods(TalkFlowClient) {
339
354
 
340
355
  if (needReinitialize && this.connectionManager) {
341
356
  await this.disconnect();
357
+ this._chat?.destroy();
358
+ this._webrtc?.destroy();
342
359
  // destroy() 로 버려야 lifecycle listener(visibilitychange/pageshow/online)가 해제된다.
343
360
  // disconnect() 만 하고 참조를 끊으면 죽은 매니저가 window 에 매달린 채 남아,
344
361
  // 사용자 전환이 잦은 앱에서 복귀 이벤트마다 유령 매니저가 함께 깨어난다.
@@ -346,6 +363,7 @@ export function applySessionMethods(TalkFlowClient) {
346
363
  if (this.pushManager) {
347
364
  this.pushManager.reset();
348
365
  this._pushEnablePromise = null;
366
+ this._pushManager = null;
349
367
  }
350
368
  this.connectionManager = null;
351
369
  this._chat = null;
@@ -359,6 +377,7 @@ export function applySessionMethods(TalkFlowClient) {
359
377
  }
360
378
 
361
379
  this.logger.info('JWT token set', { userId });
380
+ this.authSession?.replaceAccessToken(token);
362
381
  this.emit('tokenSet', { userId });
363
382
  };
364
383
 
@@ -67,6 +67,26 @@ class ApiClient {
67
67
  * @returns {Promise<Object>}
68
68
  */
69
69
  async request(method, path, options = {}) {
70
+ if (!this.authProvider || options.skipAuth) return this._request(method, path, options);
71
+ this.jwtToken = await this.authProvider.getAccessToken();
72
+ const sentToken = this.jwtToken;
73
+ const generation = this.authProvider.generation;
74
+ try { return await this._request(method, path, options); }
75
+ catch (error) {
76
+ if (error.status === 401 && ['ACCESS_UNAUTHORIZED', 'TOKEN_INVALID'].includes(error.response?.code)) {
77
+ if (generation !== this.authProvider.generation) throw error;
78
+ this.jwtToken = await this.authProvider.getAccessToken({ forceRefresh: true, rejectedToken: sentToken });
79
+ const retryToken = this.jwtToken;
80
+ const retryGeneration = this.authProvider.generation;
81
+ try { return await this._request(method, path, options); }
82
+ catch (retryError) { await this.authProvider.authenticationFailed(retryError, { rejectedToken: retryToken, generation: retryGeneration }); throw retryError; }
83
+ }
84
+ await this.authProvider.authenticationFailed(error, { rejectedToken: sentToken, generation });
85
+ throw error;
86
+ }
87
+ }
88
+
89
+ async _request(method, path, options = {}) {
70
90
  const { body, params, headers } = options;
71
91
 
72
92
  // URL 빌드
@@ -90,11 +110,12 @@ class ApiClient {
90
110
 
91
111
  let response;
92
112
  try {
93
- this.logger.debug(`${method} ${url}`, body ? { body } : '');
113
+ this.logger.debug(`${method} ${url}`);
94
114
 
95
115
  response = await fetch(url, {
96
116
  method,
97
- headers: headers ? { ...this._getHeaders(), ...headers } : this._getHeaders(),
117
+ headers: { ...this._getHeaders(), ...headers },
118
+ credentials: this.authProvider ? 'omit' : 'same-origin',
98
119
  body: body ? JSON.stringify(body) : undefined,
99
120
  signal: controller.signal
100
121
  });
@@ -133,7 +154,7 @@ class ApiClient {
133
154
  throw error;
134
155
  }
135
156
 
136
- this.logger.debug(`Response ${response.status}:`, data);
157
+ this.logger.debug(`Response ${response.status}`);
137
158
  return data;
138
159
  }
139
160
 
@@ -207,7 +228,27 @@ class ApiClient {
207
228
  * @param {number} [options.timeout=600000] - 타임아웃 (ms). 기본 10분.
208
229
  * @returns {Promise<Object>} 서버 응답 JSON (SuccessResponse 래퍼 포함)
209
230
  */
210
- upload(path, file, options = {}) {
231
+ async upload(path, file, options = {}) {
232
+ if (!this.authProvider) return this._upload(path, file, options);
233
+ this.jwtToken = await this.authProvider.getAccessToken();
234
+ const sentToken = this.jwtToken;
235
+ const generation = this.authProvider.generation;
236
+ try { return await this._upload(path, file, options); }
237
+ catch (error) {
238
+ if (error.status === 401 && ['ACCESS_UNAUTHORIZED', 'TOKEN_INVALID'].includes(error.response?.code)) {
239
+ if (generation !== this.authProvider.generation) throw error;
240
+ this.jwtToken = await this.authProvider.getAccessToken({ forceRefresh: true, rejectedToken: sentToken });
241
+ const retryToken = this.jwtToken;
242
+ const retryGeneration = this.authProvider.generation;
243
+ try { return await this._upload(path, file, options); }
244
+ catch (retryError) { await this.authProvider.authenticationFailed(retryError, { rejectedToken: retryToken, generation: retryGeneration }); throw retryError; }
245
+ }
246
+ await this.authProvider.authenticationFailed(error, { rejectedToken: sentToken, generation });
247
+ throw error;
248
+ }
249
+ }
250
+
251
+ _upload(path, file, options = {}) {
211
252
  const {
212
253
  fieldName = 'file',
213
254
  onProgress,
@@ -45,7 +45,7 @@ function base64UrlDecode(str) {
45
45
  * @throws {Error}
46
46
  */
47
47
  export function validateAndParseJWT(jwtToken, options = {}) {
48
- const { bufferSeconds = 30, validateExpiry = true } = options;
48
+ const { bufferSeconds = 30, validateExpiry = true, validateIssuedAt = false } = options;
49
49
 
50
50
  if (!jwtToken || typeof jwtToken !== 'string') {
51
51
  const error = new Error('JWT token is required');
@@ -89,7 +89,7 @@ export function validateAndParseJWT(jwtToken, options = {}) {
89
89
  }
90
90
  }
91
91
 
92
- if (payload.iat) {
92
+ if (validateIssuedAt && payload.iat) {
93
93
  const issuedAt = payload.iat * 1000;
94
94
  const clockSkew = 60 * 1000;
95
95
 
@@ -0,0 +1,8 @@
1
+ import TalkFlowClient, { AuthState, LoginResult } from '../index';
2
+ const client = new TalkFlowClient({ serverUrl: 'https://api.example.com', sessionMode: 'refresh' });
3
+ const restored: Promise<LoginResult | null> = client.restoreSession();
4
+ const state: AuthState = client.getAuthState();
5
+ const signedIn: boolean = client.isLoggedIn();
6
+ const access: Promise<string> = client.getAccessToken({ forceRefresh: true, rejectedToken: 'old' });
7
+ client.on('authStateChanged', ({ state }) => { const typed: AuthState = state; void typed; });
8
+ void [restored, state, signedIn, access];
package/types/index.d.ts CHANGED
@@ -1036,8 +1036,14 @@ export interface LoginMember {
1036
1036
  * 회사 선택 단계가 없다 — 로그인 키가 전역 유일이라 이메일 하나로 회원과 회사가 함께 정해진다.
1037
1037
  */
1038
1038
  export interface LoginResult {
1039
+ /** SDK가 저장·회전하는 갱신 자격. 직접 API를 쓰는 경우에만 응답에서 사용한다. */
1040
+ refreshToken?: string;
1041
+ /** 서버 현재 시각 및 refresh 만료 시각, Unix epoch 밀리초. */
1042
+ serverTime?: number;
1043
+ refreshExpiresAt?: number;
1039
1044
  accessToken: string;
1040
1045
  tokenType: string;
1046
+ /** Access 만료 시각, Unix epoch 초(남은 시간 아님). */
1041
1047
  expiresIn?: number;
1042
1048
  projectId: string;
1043
1049
  /** 회사의 활성 ck- 키. SDK 초기화의 `apiKey` 로 그대로 넣는다. */
@@ -1464,11 +1470,16 @@ export interface AssistantProgressSnapshot extends AssistantRoomProgressEvent {
1464
1470
  // Options — constructor & method
1465
1471
  // ============================================================================
1466
1472
 
1473
+ export type AuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'unavailable';
1474
+ export interface AccessTokenOptions { forceRefresh?: boolean; rejectedToken?: string; }
1475
+
1467
1476
  export interface TalkFlowClientOptions {
1468
1477
  /** Client API 키 (브라우저 노출 안전). */
1469
- apiKey: string;
1478
+ apiKey?: string;
1470
1479
  /** 프로젝트 ID. */
1471
- projectId: string;
1480
+ projectId?: string;
1481
+ /** refresh: 키 없이 생성 후 restoreSession/loginUser 가능. 기존 JWT만 쓰면 legacy. */
1482
+ sessionMode?: 'refresh' | 'legacy';
1472
1483
  /** JWT 토큰 — 고객사 backend 에서 발급 받아 전달. */
1473
1484
  jwtToken?: string;
1474
1485
  /** 환경. 기본 'production'. */
@@ -1867,6 +1878,8 @@ export interface TalkFlowClientEvents {
1867
1878
  connectionError: unknown;
1868
1879
  tokenSet: { userId: string };
1869
1880
  loggedOut: void;
1881
+ authStateChanged: { state: AuthState; previous: AuthState; error?: unknown };
1882
+ authError: unknown;
1870
1883
 
1871
1884
  // Chat (re-emit from ChatClient with prefixed names)
1872
1885
  chatMessage: ChatMessageReceivedPayload;
@@ -2432,6 +2445,18 @@ export default class TalkFlowClient implements SdkEventEmitter<TalkFlowClientEve
2432
2445
  /** 회원 로그인 (인스턴스) — 재로그인·계정 전환용. 성공 시 토큰을 자동 설정한다. */
2433
2446
  loginUser(credentials: { email: string; password: string }): Promise<LoginResult>;
2434
2447
 
2448
+ /** localStorage의 refresh로 복구. 세션이 없으면 null, 네트워크/서버 장애는 reject. */
2449
+ static restoreSession(options: { serverUrl: string }): Promise<LoginResult | null>;
2450
+ restoreSession(): Promise<LoginResult | null>;
2451
+ /** 캐시된 UI 상태. 호출 자체는 통신하지 않으며 서버 권한 판정은 요청 시 수행된다. */
2452
+ getAuthState(): AuthState;
2453
+ isLoggedIn(): boolean;
2454
+ /** 유효한 메모리 토큰 반환. 필요할 때만 갱신하며 동시 요청은 하나로 합친다. */
2455
+ getAccessToken(options?: AccessTokenOptions): Promise<string>;
2456
+ /** 관리/캘린더 등 SDK 밖 호출용. 토큰 준비와 인증 오류 1회 재시도를 공통 적용한다. */
2457
+ request<T = unknown>(method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string,
2458
+ options?: { body?: unknown; params?: Record<string, unknown>; headers?: Record<string, string> }): Promise<T>;
2459
+
2435
2460
  /** 내 정보 조회. */
2436
2461
  getMyProfile(): Promise<MyProfile>;
2437
2462
 
@@ -2448,7 +2473,7 @@ export default class TalkFlowClient implements SdkEventEmitter<TalkFlowClientEve
2448
2473
  timeZone?: string;
2449
2474
  }): Promise<MyProfile>;
2450
2475
 
2451
- /** 비밀번호 변경 — 현재 비밀번호 재확인. 토큰은 그대로 유효해 재로그인이 필요 없다. */
2476
+ /** 비밀번호 변경 — 기존 세션을 모두 폐기하고 이 브라우저에 새 세션을 자동 채택한다. */
2452
2477
  changeMyPassword(params: { currentPassword: string; newPassword: string }): Promise<unknown>;
2453
2478
 
2454
2479
  // Connection lifecycle