@vibexnpm/talkx 2.3.1

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.
@@ -0,0 +1,517 @@
1
+ /**
2
+ * ConnectionManager
3
+ * WebSocket/STOMP 연결 관리 (Chat, WebRTC 공유)
4
+ */
5
+
6
+ import EventEmitter from '../utils/EventEmitter.js';
7
+ import Logger from '../utils/Logger.js';
8
+ import { ConnectionState, ErrorTypes, WebSocketPaths, DefaultConfig, LogLevel } from '../constants.js';
9
+ import { Client as StompClient } from '@stomp/stompjs';
10
+ import SockJS from 'sockjs-client';
11
+
12
+ // STOMP Client 가져오기 (CDN 사용 시 전역 객체 우선)
13
+ const getStompClient = () => {
14
+ // noinspection JSUnresolvedReference
15
+ if (typeof window !== 'undefined' && window.StompJs && window.StompJs.Client) {
16
+ return window.StompJs.Client;
17
+ }
18
+ return StompClient;
19
+ };
20
+
21
+ // SockJS 가져오기 (CDN 사용 시 전역 객체 우선)
22
+ const getSockJS = () => {
23
+ // noinspection JSUnresolvedReference
24
+ if (typeof window !== 'undefined' && window.SockJS) {
25
+ return window.SockJS;
26
+ }
27
+ return SockJS;
28
+ };
29
+
30
+ class ConnectionManager extends EventEmitter {
31
+ /**
32
+ * @param {Object} options
33
+ * @param {string} options.serverUrl - 서버 URL
34
+ * @param {string} options.jwtToken - JWT 토큰
35
+ * @param {string} options.apiKey - API 키
36
+ * @param {string} options.projectId - 프로젝트 ID
37
+ * @param {boolean} [options.useSockJS=true] - SockJS 사용 여부
38
+ * @param {number} [options.reconnectDelay] - 재연결 지연 시간
39
+ * @param {number} [options.maxReconnectAttempts] - 최대 재연결 시도 횟수
40
+ * @param {number} [options.heartbeatIncoming] - 수신 하트비트 간격
41
+ * @param {number} [options.heartbeatOutgoing] - 발신 하트비트 간격
42
+ * @param {number} [options.logLevel] - 로그 레벨
43
+ */
44
+ constructor(options) {
45
+ super();
46
+
47
+ this.serverUrl = options.serverUrl.replace(/\/$/, '');
48
+ this.jwtToken = options.jwtToken;
49
+ this.apiKey = options.apiKey;
50
+ this.projectId = options.projectId;
51
+ this.useSockJS = options.useSockJS !== false;
52
+
53
+ this.reconnectDelay = options.reconnectDelay || DefaultConfig.reconnectDelay;
54
+ this.maxReconnectAttempts = options.maxReconnectAttempts || DefaultConfig.maxReconnectAttempts;
55
+ this.heartbeatIncoming = options.heartbeatIncoming || DefaultConfig.heartbeatIncoming;
56
+ this.heartbeatOutgoing = options.heartbeatOutgoing || DefaultConfig.heartbeatOutgoing;
57
+
58
+ this.logger = new Logger(options.logLevel || LogLevel.WARN, 'ConnectionManager');
59
+
60
+ // 상태
61
+ this.state = ConnectionState.DISCONNECTED;
62
+ this.stompClient = null;
63
+ this.reconnectAttempts = 0;
64
+ this.subscriptions = new Map();
65
+ this.pendingSubscriptions = [];
66
+ this.userId = null;
67
+ }
68
+
69
+ /**
70
+ * 연결 상태 변경
71
+ * @private
72
+ */
73
+ _setState(state) {
74
+ const prevState = this.state;
75
+ this.state = state;
76
+ this.emit('stateChange', { state, prevState });
77
+ this.logger.info(`State changed: ${prevState} -> ${state}`);
78
+ }
79
+
80
+ /**
81
+ * WebSocket URL 생성.
82
+ *
83
+ * <p>JWT 는 STOMP connectHeaders 의 Authorization 헤더로만 전달합니다.
84
+ * URL query parameter 에 토큰을 넣지 않습니다 — 서버 로그, 프록시 로그,
85
+ * 브라우저 히스토리에 JWT 가 평문 노출되는 것을 방지.</p>
86
+ *
87
+ * @private
88
+ */
89
+ _getWebSocketUrl() {
90
+ const endpoint = this.useSockJS
91
+ ? WebSocketPaths.SOCKJS_ENDPOINT
92
+ : WebSocketPaths.NATIVE_ENDPOINT;
93
+
94
+ return `${this.serverUrl}${endpoint}`;
95
+ }
96
+
97
+ /**
98
+ * STOMP 클라이언트 생성
99
+ * @private
100
+ */
101
+ _createStompClient() {
102
+ const wsUrl = this._getWebSocketUrl();
103
+
104
+ const config = {
105
+ connectHeaders: {
106
+ 'Authorization': this.jwtToken.startsWith('Bearer ')
107
+ ? this.jwtToken
108
+ : `Bearer ${this.jwtToken}`,
109
+ 'X-API-KEY': this.apiKey,
110
+ 'X-PROJECT-ID': this.projectId
111
+ },
112
+ heartbeatIncoming: this.heartbeatIncoming,
113
+ heartbeatOutgoing: this.heartbeatOutgoing,
114
+ reconnectDelay: this.reconnectDelay,
115
+ debug: (str) => {
116
+ if (this.logger.level <= LogLevel.DEBUG) {
117
+ this.logger.debug('STOMP:', str);
118
+ }
119
+ },
120
+ onConnect: (frame) => this._onConnect(frame),
121
+ onDisconnect: (frame) => this._onDisconnect(frame),
122
+ onStompError: (frame) => this._onStompError(frame),
123
+ onWebSocketClose: (event) => this._onWebSocketClose(event),
124
+ onWebSocketError: (event) => this._onWebSocketError(event)
125
+ };
126
+
127
+ if (this.useSockJS) {
128
+ const SockJS = getSockJS();
129
+ config.webSocketFactory = () => new SockJS(wsUrl);
130
+ } else {
131
+ // Native WebSocket — 토큰은 connectHeaders 로만 전달 (URL 노출 방지)
132
+ const wsProtocol = this.serverUrl.startsWith('https') ? 'wss' : 'ws';
133
+ const wsHost = this.serverUrl.replace(/^https?:\/\//, '');
134
+ config.brokerURL = `${wsProtocol}://${wsHost}${WebSocketPaths.NATIVE_ENDPOINT}`;
135
+ }
136
+
137
+ const Client = getStompClient();
138
+ return new Client(config);
139
+ }
140
+
141
+ /**
142
+ * 연결
143
+ * @returns {Promise<void>}
144
+ */
145
+ async connect() {
146
+ if (this.state === ConnectionState.CONNECTED) {
147
+ this.logger.warn('Already connected');
148
+ return;
149
+ }
150
+
151
+ if (this.state === ConnectionState.CONNECTING) {
152
+ this.logger.warn('Connection in progress');
153
+ return;
154
+ }
155
+
156
+ this._setState(ConnectionState.CONNECTING);
157
+ this.reconnectAttempts = 0;
158
+
159
+ return new Promise((resolve, reject) => {
160
+ this._connectResolve = resolve;
161
+ this._connectReject = reject;
162
+
163
+ try {
164
+ this.stompClient = this._createStompClient();
165
+ this.stompClient.activate();
166
+ } catch (error) {
167
+ this._setState(ConnectionState.ERROR);
168
+ this._drainPendingSubscriptions('Connection activation failed');
169
+ reject(error);
170
+ }
171
+ });
172
+ }
173
+
174
+ /**
175
+ * 연결 성공 콜백
176
+ * @private
177
+ */
178
+ _onConnect(frame) {
179
+ this._setState(ConnectionState.CONNECTED);
180
+ this.reconnectAttempts = 0;
181
+ this.logger.info('Connected to server', frame);
182
+
183
+ // 기존 구독 재등록 (재연결 시)
184
+ this._restoreSubscriptions();
185
+
186
+ // 대기 중인 구독 처리
187
+ this._processPendingSubscriptions();
188
+
189
+ this.emit('connected', { frame });
190
+
191
+ if (this._connectResolve) {
192
+ this._connectResolve();
193
+ this._connectResolve = null;
194
+ this._connectReject = null;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * 연결 해제 콜백
200
+ * @private
201
+ */
202
+ _onDisconnect(frame) {
203
+ this.logger.info('Disconnected from server', frame);
204
+ this._setState(ConnectionState.DISCONNECTED);
205
+ this.emit('disconnected', { frame });
206
+ }
207
+
208
+ /**
209
+ * STOMP 에러 콜백
210
+ * @private
211
+ */
212
+ _onStompError(frame) {
213
+ this.logger.error('STOMP error:', frame);
214
+ this._setState(ConnectionState.ERROR);
215
+
216
+ const error = {
217
+ type: ErrorTypes.CONNECTION_FAILED,
218
+ message: frame.headers?.message || 'STOMP error',
219
+ frame
220
+ };
221
+
222
+ // terminal 전이 — 대기 중 구독 Promise 가 영구 hang 되지 않도록 drain.
223
+ this._drainPendingSubscriptions(`STOMP error: ${error.message}`);
224
+
225
+ this.emit('error', error);
226
+
227
+ if (this._connectReject) {
228
+ this._connectReject(new Error(error.message));
229
+ this._connectResolve = null;
230
+ this._connectReject = null;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * WebSocket 종료 콜백
236
+ * @private
237
+ */
238
+ _onWebSocketClose(event) {
239
+ this.logger.warn('WebSocket closed:', event);
240
+
241
+ if (this.state !== ConnectionState.DISCONNECTED) {
242
+ this._handleReconnect();
243
+ }
244
+ }
245
+
246
+ /**
247
+ * WebSocket 에러 콜백
248
+ * @private
249
+ */
250
+ _onWebSocketError(event) {
251
+ this.logger.error('WebSocket error:', event);
252
+
253
+ // 초기 연결 실패 경로 — _connectReject 가 살아있다는 건 아직 한 번도
254
+ // CONNECTED 된 적이 없다는 뜻. pending 구독도 함께 drain 해서 호출자 hang 방지.
255
+ // 이미 CONNECTED 후 일시 끊김은 _onWebSocketClose → _handleReconnect 로 흘러
256
+ // 재연결 성공 시 _processPendingSubscriptions 가 살린다.
257
+ if (this._connectReject) {
258
+ this._drainPendingSubscriptions('WebSocket connection failed');
259
+ this._connectReject(new Error('WebSocket connection failed'));
260
+ this._connectResolve = null;
261
+ this._connectReject = null;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * 재연결 처리.
267
+ *
268
+ * <p>max 초과 시 STOMP 내부 재연결 루프(reconnectDelay) 를 차단하기 위해
269
+ * stompClient 를 반드시 deactivate 하고 참조를 해제한다. state 변경만으로는
270
+ * STOMP 자체가 소유한 타이머를 멈출 수 없음.</p>
271
+ *
272
+ * @private
273
+ */
274
+ _handleReconnect() {
275
+ this.reconnectAttempts++;
276
+
277
+ if (this.reconnectAttempts > this.maxReconnectAttempts) {
278
+ if (this.stompClient) {
279
+ try {
280
+ this.stompClient.deactivate();
281
+ } catch (error) {
282
+ this.logger.warn('Error deactivating stomp client on max reconnect:', error);
283
+ }
284
+ this.stompClient = null;
285
+ }
286
+
287
+ // terminal 전이 — 재연결 성공으로 살릴 기회가 사라졌으므로 pending 도 drain.
288
+ this._drainPendingSubscriptions('Max reconnection attempts exceeded');
289
+
290
+ this._setState(ConnectionState.ERROR);
291
+ this.emit('error', {
292
+ type: ErrorTypes.CONNECTION_LOST,
293
+ message: 'Max reconnection attempts exceeded'
294
+ });
295
+ return;
296
+ }
297
+
298
+ this._setState(ConnectionState.RECONNECTING);
299
+ this.emit('reconnecting', { attempt: this.reconnectAttempts });
300
+ this.logger.info(`Reconnecting... attempt ${this.reconnectAttempts}`);
301
+ }
302
+
303
+ /**
304
+ * 기존 구독 재등록 (재연결 시)
305
+ * @private
306
+ */
307
+ _restoreSubscriptions() {
308
+ if (this.subscriptions.size === 0) return;
309
+
310
+ this.logger.info(`Restoring ${this.subscriptions.size} subscriptions after reconnect`);
311
+
312
+ const previousSubscriptions = new Map(this.subscriptions);
313
+ this.subscriptions.clear();
314
+
315
+ previousSubscriptions.forEach(({ callback, headers }, destination) => {
316
+ this._subscribeInternal(destination, callback, headers);
317
+ this.logger.debug(`Restored subscription: ${destination}`);
318
+ });
319
+ }
320
+
321
+ /**
322
+ * 대기 중인 구독 처리
323
+ * @private
324
+ */
325
+ _processPendingSubscriptions() {
326
+ while (this.pendingSubscriptions.length > 0) {
327
+ const { destination, callback, headers, resolve } = this.pendingSubscriptions.shift();
328
+ const subscription = this._subscribeInternal(destination, callback, headers);
329
+ if (resolve) {
330
+ resolve(subscription);
331
+ }
332
+ }
333
+ }
334
+
335
+ /**
336
+ * 내부 구독 처리
337
+ * @private
338
+ */
339
+ _subscribeInternal(destination, callback, headers = {}) {
340
+ const subscription = this.stompClient.subscribe(destination, (message) => {
341
+ try {
342
+ const body = JSON.parse(message.body);
343
+ callback(body, message);
344
+ } catch (error) {
345
+ this.logger.error('Failed to parse message:', error);
346
+ callback(message.body, message);
347
+ }
348
+ }, headers);
349
+
350
+ // 재연결 시 복구를 위해 callback, headers도 함께 저장
351
+ this.subscriptions.set(destination, { subscription, callback, headers });
352
+ this.logger.debug(`Subscribed to: ${destination}`);
353
+
354
+ return subscription;
355
+ }
356
+
357
+ /**
358
+ * 채널 구독
359
+ * @param {string} destination - 구독 경로
360
+ * @param {Function} callback - 메시지 콜백
361
+ * @param {Object} [headers] - 추가 헤더
362
+ * @returns {Promise<Object>} - 구독 객체
363
+ */
364
+ subscribe(destination, callback, headers = {}) {
365
+ // 이미 구독 중이면 기존 구독 해제 후 재구독
366
+ if (this.subscriptions.has(destination)) {
367
+ this.unsubscribe(destination);
368
+ }
369
+
370
+ if (this.state !== ConnectionState.CONNECTED) {
371
+ return new Promise((resolve, reject) => {
372
+ this.pendingSubscriptions.push({ destination, callback, headers, resolve, reject });
373
+ this.logger.debug(`Queued subscription for: ${destination}`);
374
+ });
375
+ }
376
+
377
+ return Promise.resolve(this._subscribeInternal(destination, callback, headers));
378
+ }
379
+
380
+ /**
381
+ * 구독 해제
382
+ * @param {string} destination - 구독 경로
383
+ */
384
+ unsubscribe(destination) {
385
+ const entry = this.subscriptions.get(destination);
386
+ if (entry) {
387
+ entry.subscription.unsubscribe();
388
+ this.subscriptions.delete(destination);
389
+ this.logger.debug(`Unsubscribed from: ${destination}`);
390
+ }
391
+ }
392
+
393
+ /**
394
+ * 모든 구독 해제
395
+ */
396
+ unsubscribeAll() {
397
+ this.subscriptions.forEach(({ subscription }, destination) => {
398
+ subscription.unsubscribe();
399
+ this.logger.debug(`Unsubscribed from: ${destination}`);
400
+ });
401
+ this.subscriptions.clear();
402
+ }
403
+
404
+ /**
405
+ * 메시지 전송
406
+ * @param {string} destination - 전송 경로
407
+ * @param {Object} body - 메시지 본문
408
+ * @param {Object} [headers] - 추가 헤더
409
+ */
410
+ send(destination, body, headers = {}) {
411
+ if (this.state !== ConnectionState.CONNECTED) {
412
+ this.logger.warn('Cannot send message: not connected');
413
+ return false;
414
+ }
415
+
416
+ this.stompClient.publish({
417
+ destination,
418
+ body: JSON.stringify(body),
419
+ headers
420
+ });
421
+
422
+ this.logger.debug(`Sent to ${destination}:`, body);
423
+ return true;
424
+ }
425
+
426
+ /**
427
+ * JWT 토큰 업데이트
428
+ * @param {string} token
429
+ */
430
+ updateToken(token) {
431
+ this.jwtToken = token;
432
+
433
+ if (this.stompClient) {
434
+ this.stompClient.connectHeaders = {
435
+ ...this.stompClient.connectHeaders,
436
+ 'Authorization': token.startsWith('Bearer ') ? token : `Bearer ${token}`
437
+ };
438
+ }
439
+ }
440
+
441
+ /**
442
+ * 연결 해제.
443
+ *
444
+ * <p>대기 중 구독이 남아 있으면 각 Promise 를 reject 하고 큐를 비운다.
445
+ * clear 만 하면 호출자 {@code await} 가 영원히 hang 된다.</p>
446
+ */
447
+ async disconnect() {
448
+ this._drainPendingSubscriptions('Disconnected before subscription completed');
449
+
450
+ if (this.stompClient) {
451
+ this.unsubscribeAll();
452
+ await this.stompClient.deactivate();
453
+ this.stompClient = null;
454
+ }
455
+
456
+ this._setState(ConnectionState.DISCONNECTED);
457
+ this.logger.info('Disconnected');
458
+ }
459
+
460
+ /**
461
+ * 대기 중 구독 Promise 일괄 reject + 큐 비움.
462
+ * @private
463
+ */
464
+ _drainPendingSubscriptions(reason) {
465
+ if (this.pendingSubscriptions.length === 0) return;
466
+
467
+ const pending = this.pendingSubscriptions;
468
+ this.pendingSubscriptions = [];
469
+
470
+ pending.forEach(({ destination, reject }) => {
471
+ if (reject) {
472
+ reject(new Error(`${reason}: ${destination}`));
473
+ }
474
+ });
475
+
476
+ this.logger.debug(`Drained ${pending.length} pending subscriptions: ${reason}`);
477
+ }
478
+
479
+ /**
480
+ * 연결 상태 확인
481
+ * @returns {boolean}
482
+ */
483
+ isConnected() {
484
+ return this.state === ConnectionState.CONNECTED;
485
+ }
486
+
487
+ /**
488
+ * 현재 상태 반환
489
+ * @returns {string}
490
+ */
491
+ getState() {
492
+ return this.state;
493
+ }
494
+
495
+ /**
496
+ * 로그 레벨 설정
497
+ * @param {number} level
498
+ */
499
+ setLogLevel(level) {
500
+ this.logger.setLevel(level);
501
+ }
502
+
503
+ /**
504
+ * 리소스 정리.
505
+ *
506
+ * <p>{@link #disconnect} 가 async 이므로 await 필수. 최상위 {@link TalkFlowClient#destroy}
507
+ * 는 별도 {@code await this.disconnect()} 선행으로 이미 완충되지만,
508
+ * ConnectionManager 를 직접 소유한 사용자가 destroy 만 호출하는 경로도 안전해야 함.</p>
509
+ */
510
+ async destroy() {
511
+ await this.disconnect();
512
+ this.removeAllListeners();
513
+ this.logger.info('ConnectionManager destroyed');
514
+ }
515
+ }
516
+
517
+ export default ConnectionManager;
package/src/index.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * TalkFlow SDK
3
+ * 채팅 + WebRTC 통합 클라이언트 라이브러리
4
+ *
5
+ * <h2>인증 플로우 (중요)</h2>
6
+ *
7
+ * JWT 토큰은 반드시 고객사 backend 에서 발급받아 SDK 에 전달해야 합니다.
8
+ * 브라우저에서 직접 사용자를 등록/인증하는 기능은 제공하지 않습니다 (보안).
9
+ *
10
+ * 자세한 내용은 README 의 "프로덕션 인증 플로우" 섹션 참고.
11
+ *
12
+ * @example
13
+ * // === 빠른 시작 (개발/프로토타입) — Server Key 사용 ===
14
+ * import TalkFlowClient from '@talkflow/sdk';
15
+ *
16
+ * const client = new TalkFlowClient({
17
+ * apiKey: 'SERVER_KEY', // 개발용 Server Key (프로덕션에서는 쓰지 마세요)
18
+ * projectId: 'your-project-id',
19
+ * env: 'development'
20
+ * });
21
+ *
22
+ * await client.registerUser({ userId: 'user-123', nickname: '홍길동' });
23
+ * await client.connect();
24
+ * await client.chat.sendTextMessage('room-id', '안녕하세요!');
25
+ *
26
+ * @example
27
+ * // === 프로덕션 — Client Key + Backend JWT 발급 ===
28
+ * // 1단계: 고객사 backend 에서 Server Key 로 JWT 발급
29
+ * // POST /api/v1/users/auth (X-API-KEY: SERVER_KEY)
30
+ * // → { accessToken }
31
+ *
32
+ * // 2단계: 브라우저 SDK 는 Client Key + JWT 로 초기화
33
+ * const client = new TalkFlowClient({
34
+ * apiKey: 'CLIENT_KEY', // 브라우저 노출 안전 (registerUser 서버 차단)
35
+ * projectId: 'your-project-id',
36
+ * jwtToken: receivedJwt, // 백엔드로부터 받은 JWT
37
+ * env: 'production'
38
+ * });
39
+ *
40
+ * await client.connect();
41
+ * await client.chat.sendTextMessage('room-id', '안녕하세요!');
42
+ */
43
+
44
+ // 메인 클라이언트
45
+ import TalkFlowClient from './TalkFlowClient.js';
46
+
47
+ // 상수
48
+ export {
49
+ ConnectionState,
50
+ ErrorTypes,
51
+ SignalTypes,
52
+ ChatMessageType,
53
+ ChatRoomType,
54
+ AssistantMode,
55
+ RoomAiType,
56
+ EngagementIntensity,
57
+ PmPromptLayerScope,
58
+ PmPromptLayerEditorType,
59
+ SenderType,
60
+ PersonaRole,
61
+ SummarizeFormat,
62
+ RoomListEventType,
63
+ WebSocketPaths,
64
+ DefaultConfig,
65
+ LogLevel,
66
+ Environment,
67
+ Endpoints,
68
+ getServerUrl
69
+ } from './constants.js';
70
+
71
+ // 유틸리티
72
+ export { default as EventEmitter } from './utils/EventEmitter.js';
73
+ export { default as Logger } from './utils/Logger.js';
74
+ export {
75
+ validateAndParseJWT,
76
+ extractUserIdFromJWT,
77
+ isJWTExpired,
78
+ getJWTRemainingTime,
79
+ decodeJWTPayload
80
+ } from './utils/jwtUtils.js';
81
+
82
+ // 코어
83
+ export { default as ConnectionManager } from './core/ConnectionManager.js';
84
+
85
+ // 채팅
86
+ export { default as ChatClient } from './chat/ChatClient.js';
87
+
88
+ // WebRTC
89
+ export { default as WebRTCClient } from './webrtc/WebRTCClient.js';
90
+ export { default as MediaStreamManager } from './webrtc/MediaStreamManager.js';
91
+ export { default as PeerConnectionManager } from './webrtc/PeerConnectionManager.js';
92
+
93
+ // Push — PushError/PushErrorCode 는 호출자가 instanceof / code 분기에 사용 가능하도록 noamed export.
94
+ export { default as PushManager, PushError, PushErrorCode } from './push/PushManager.js';
95
+
96
+ // 기본 내보내기
97
+ export default TalkFlowClient;