@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,213 @@
1
+ /**
2
+ * JWT 유틸리티 함수
3
+ * JWT 토큰 파싱 및 검증
4
+ */
5
+
6
+ import { ErrorTypes } from '../constants.js';
7
+
8
+ /**
9
+ * Bearer 접두사 제거
10
+ * @param {string} token - JWT 토큰
11
+ * @returns {string}
12
+ */
13
+ function stripBearer(token) {
14
+ if (!token || typeof token !== 'string') {
15
+ return '';
16
+ }
17
+ return token.replace(/^Bearer\s+/i, '');
18
+ }
19
+
20
+ /**
21
+ * Base64URL 디코딩 (UTF-8 안전)
22
+ * @param {string} str - Base64URL 인코딩된 문자열
23
+ * @returns {string}
24
+ */
25
+ function base64UrlDecode(str) {
26
+ let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
27
+ const padding = base64.length % 4;
28
+ if (padding) {
29
+ base64 += '='.repeat(4 - padding);
30
+ }
31
+ // atob()은 Latin-1 기반이라 멀티바이트 UTF-8(한국어 등)이 깨짐
32
+ // → Uint8Array 변환 후 TextDecoder로 UTF-8 디코딩
33
+ const binaryString = atob(base64);
34
+ const bytes = Uint8Array.from(binaryString, c => c.charCodeAt(0));
35
+ return new TextDecoder().decode(bytes);
36
+ }
37
+
38
+ /**
39
+ * JWT 토큰 검증 및 파싱
40
+ * @param {string} jwtToken - JWT 토큰 (Bearer 접두사 포함 가능)
41
+ * @param {Object} [options] - 옵션
42
+ * @param {number} [options.bufferSeconds=30] - 만료 버퍼 시간 (초)
43
+ * @param {boolean} [options.validateExpiry=true] - 만료 시간 검증 여부
44
+ * @returns {{userId: string, payload: Object}}
45
+ * @throws {Error}
46
+ */
47
+ export function validateAndParseJWT(jwtToken, options = {}) {
48
+ const { bufferSeconds = 30, validateExpiry = true } = options;
49
+
50
+ if (!jwtToken || typeof jwtToken !== 'string') {
51
+ const error = new Error('JWT token is required');
52
+ error.code = ErrorTypes.JWT_INVALID;
53
+ throw error;
54
+ }
55
+
56
+ const tokenWithoutBearer = stripBearer(jwtToken);
57
+ const parts = tokenWithoutBearer.split('.');
58
+
59
+ if (parts.length !== 3) {
60
+ const error = new Error('Invalid JWT format: expected 3 parts separated by dots');
61
+ error.code = ErrorTypes.JWT_INVALID;
62
+ throw error;
63
+ }
64
+
65
+ let payload;
66
+ try {
67
+ payload = JSON.parse(base64UrlDecode(parts[1]));
68
+ } catch (e) {
69
+ const error = new Error('Invalid JWT: failed to decode payload');
70
+ error.code = ErrorTypes.JWT_PARSE_FAILED;
71
+ error.originalError = e;
72
+ throw error;
73
+ }
74
+
75
+ if (validateExpiry && payload.exp) {
76
+ const expiryTime = payload.exp * 1000;
77
+ const bufferTime = bufferSeconds * 1000;
78
+ const now = Date.now();
79
+
80
+ if (expiryTime < now + bufferTime) {
81
+ const error = new Error(
82
+ expiryTime < now
83
+ ? 'JWT token expired'
84
+ : `JWT token expires within ${bufferSeconds} seconds`
85
+ );
86
+ error.code = ErrorTypes.JWT_EXPIRED;
87
+ error.expiredAt = new Date(expiryTime).toISOString();
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ if (payload.iat) {
93
+ const issuedAt = payload.iat * 1000;
94
+ const clockSkew = 60 * 1000;
95
+
96
+ if (issuedAt > Date.now() + clockSkew) {
97
+ const error = new Error('JWT token issued in the future');
98
+ error.code = ErrorTypes.JWT_INVALID;
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ const userId = payload.userId || payload.sub || payload.user_id;
104
+
105
+ if (!userId) {
106
+ const error = new Error('Cannot extract userId from JWT: missing userId, sub, or user_id claim');
107
+ error.code = ErrorTypes.JWT_INVALID;
108
+ throw error;
109
+ }
110
+
111
+ return { userId, payload };
112
+ }
113
+
114
+ /**
115
+ * JWT에서 userId만 추출
116
+ * @param {string} jwtToken - JWT 토큰
117
+ * @returns {string|null}
118
+ */
119
+ export function extractUserIdFromJWT(jwtToken) {
120
+ try {
121
+ const tokenWithoutBearer = stripBearer(jwtToken);
122
+ const parts = tokenWithoutBearer.split('.');
123
+
124
+ if (parts.length !== 3) {
125
+ return null;
126
+ }
127
+
128
+ const payload = JSON.parse(base64UrlDecode(parts[1]));
129
+ return payload.userId || payload.sub || payload.user_id || null;
130
+ } catch (error) {
131
+ console.error('Failed to extract userId from JWT:', error);
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * JWT 만료 여부 확인
138
+ * @param {string} jwtToken - JWT 토큰
139
+ * @param {number} [bufferSeconds=0] - 버퍼 시간 (초)
140
+ * @returns {boolean}
141
+ */
142
+ export function isJWTExpired(jwtToken, bufferSeconds = 0) {
143
+ try {
144
+ const tokenWithoutBearer = stripBearer(jwtToken);
145
+ const parts = tokenWithoutBearer.split('.');
146
+
147
+ if (parts.length !== 3) {
148
+ return true;
149
+ }
150
+
151
+ const payload = JSON.parse(base64UrlDecode(parts[1]));
152
+
153
+ if (!payload.exp) {
154
+ return false;
155
+ }
156
+
157
+ const expiryTime = payload.exp * 1000;
158
+ const bufferTime = bufferSeconds * 1000;
159
+
160
+ return expiryTime < Date.now() + bufferTime;
161
+ } catch (error) {
162
+ console.error('Failed to check JWT expiration:', error);
163
+ return true;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * JWT 남은 유효 시간 반환
169
+ * @param {string} jwtToken - JWT 토큰
170
+ * @returns {number} - 밀리초, 만료 시 음수
171
+ */
172
+ export function getJWTRemainingTime(jwtToken) {
173
+ try {
174
+ const tokenWithoutBearer = stripBearer(jwtToken);
175
+ const parts = tokenWithoutBearer.split('.');
176
+
177
+ if (parts.length !== 3) {
178
+ return -1;
179
+ }
180
+
181
+ const payload = JSON.parse(base64UrlDecode(parts[1]));
182
+
183
+ if (!payload.exp) {
184
+ return Infinity;
185
+ }
186
+
187
+ return (payload.exp * 1000) - Date.now();
188
+ } catch (error) {
189
+ console.error('Failed to get JWT remaining time:', error);
190
+ return -1;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * JWT payload 디코딩 (검증 없이)
196
+ * @param {string} jwtToken - JWT 토큰
197
+ * @returns {Object|null}
198
+ */
199
+ export function decodeJWTPayload(jwtToken) {
200
+ try {
201
+ const tokenWithoutBearer = stripBearer(jwtToken);
202
+ const parts = tokenWithoutBearer.split('.');
203
+
204
+ if (parts.length !== 3) {
205
+ return null;
206
+ }
207
+
208
+ return JSON.parse(base64UrlDecode(parts[1]));
209
+ } catch (error) {
210
+ console.error('Failed to decode JWT payload:', error);
211
+ return null;
212
+ }
213
+ }
@@ -0,0 +1,478 @@
1
+ /**
2
+ * MediaStreamManager
3
+ * 로컬 미디어 스트림 관리 (카메라/마이크/화면공유)
4
+ */
5
+
6
+ import EventEmitter from '../utils/EventEmitter.js';
7
+ import Logger from '../utils/Logger.js';
8
+ import { ErrorTypes, LogLevel } from '../constants.js';
9
+
10
+ class MediaStreamManager extends EventEmitter {
11
+ /**
12
+ * @param {Object} options
13
+ * @param {number} [options.logLevel] - 로그 레벨
14
+ */
15
+ constructor(options = {}) {
16
+ super();
17
+
18
+ this.localStream = null;
19
+ this.screenStream = null;
20
+ this.videoEnabled = true;
21
+ this.audioEnabled = true;
22
+
23
+ this.logger = new Logger(options.logLevel || LogLevel.WARN, 'MediaStreamManager');
24
+ this._deviceChangeHandler = null;
25
+ }
26
+
27
+ /**
28
+ * 사용자 미디어 가져오기
29
+ * @param {Object} constraints - 미디어 제약조건
30
+ * @returns {Promise<MediaStream>}
31
+ */
32
+ async getUserMedia(constraints = { video: true, audio: true }) {
33
+ try {
34
+ this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
35
+ this.videoEnabled = constraints.video !== false;
36
+ this.audioEnabled = constraints.audio !== false;
37
+
38
+ this.emit('streamStarted', { stream: this.localStream });
39
+ this.logger.info('User media stream started');
40
+ return this.localStream;
41
+ } catch (error) {
42
+ this.logger.error('Failed to get user media:', error);
43
+ this.emit('error', {
44
+ type: ErrorTypes.MEDIA_ACCESS_DENIED,
45
+ message: this._getMediaErrorMessage(error),
46
+ error
47
+ });
48
+ throw error;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * 미디어 에러 메시지 변환
54
+ * @private
55
+ * @param {Error} error - 에러 객체
56
+ * @returns {string} 변환된 에러 메시지
57
+ */
58
+ _getMediaErrorMessage(error) {
59
+ switch (error.name) {
60
+ case 'NotAllowedError':
61
+ return 'Camera/Microphone permission denied';
62
+ case 'NotFoundError':
63
+ return 'Camera/Microphone not found';
64
+ case 'NotReadableError':
65
+ return 'Camera/Microphone is already in use';
66
+ case 'OverconstrainedError':
67
+ return 'Camera/Microphone constraints cannot be satisfied';
68
+ case 'AbortError':
69
+ return 'Media access aborted';
70
+ default:
71
+ return error.message || 'Unknown media error';
72
+ }
73
+ }
74
+
75
+ /**
76
+ * 화면 공유 가져오기
77
+ * @param {Object} [options] - 화면 공유 옵션
78
+ * @returns {Promise<MediaStream>}
79
+ */
80
+ async getDisplayMedia(options = { video: true, audio: false }) {
81
+ try {
82
+ this.screenStream = await navigator.mediaDevices.getDisplayMedia(options);
83
+
84
+ // 화면 공유 종료 감지
85
+ this.screenStream.getVideoTracks()[0].onended = () => {
86
+ this.emit('screenShareEnded', {});
87
+ this.screenStream = null;
88
+ };
89
+
90
+ this.emit('screenShareStarted', { stream: this.screenStream });
91
+ this.logger.info('Screen share stream started');
92
+ return this.screenStream;
93
+ } catch (error) {
94
+ this.logger.error('Failed to get display media:', error);
95
+ this.emit('error', {
96
+ type: ErrorTypes.SCREEN_SHARE_DENIED,
97
+ message: error.name === 'NotAllowedError'
98
+ ? 'Screen share permission denied'
99
+ : error.message,
100
+ error
101
+ });
102
+ throw error;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * 비디오 토글
108
+ * @returns {boolean} 현재 상태
109
+ */
110
+ toggleVideo() {
111
+ if (!this.localStream) {
112
+ this.logger.warn('No local stream available');
113
+ return this.videoEnabled;
114
+ }
115
+
116
+ const videoTracks = this.localStream.getVideoTracks();
117
+ if (videoTracks.length === 0) {
118
+ this.logger.warn('No video track available');
119
+ return this.videoEnabled;
120
+ }
121
+
122
+ this.videoEnabled = !this.videoEnabled;
123
+ videoTracks.forEach(track => {
124
+ track.enabled = this.videoEnabled;
125
+ });
126
+
127
+ this.emit('videoToggled', { enabled: this.videoEnabled });
128
+ this.logger.debug(`Video toggled: ${this.videoEnabled}`);
129
+ return this.videoEnabled;
130
+ }
131
+
132
+ /**
133
+ * 오디오 토글
134
+ * @returns {boolean} 현재 상태
135
+ */
136
+ toggleAudio() {
137
+ if (!this.localStream) {
138
+ this.logger.warn('No local stream available');
139
+ return this.audioEnabled;
140
+ }
141
+
142
+ const audioTracks = this.localStream.getAudioTracks();
143
+ if (audioTracks.length === 0) {
144
+ this.logger.warn('No audio track available');
145
+ return this.audioEnabled;
146
+ }
147
+
148
+ this.audioEnabled = !this.audioEnabled;
149
+ audioTracks.forEach(track => {
150
+ track.enabled = this.audioEnabled;
151
+ });
152
+
153
+ this.emit('audioToggled', { enabled: this.audioEnabled });
154
+ this.logger.debug(`Audio toggled: ${this.audioEnabled}`);
155
+ return this.audioEnabled;
156
+ }
157
+
158
+ /**
159
+ * 비디오 상태 설정
160
+ * @param {boolean} enabled
161
+ */
162
+ setVideoEnabled(enabled) {
163
+ if (!this.localStream) {
164
+ this.logger.warn('No local stream available');
165
+ return;
166
+ }
167
+
168
+ const videoTracks = this.localStream.getVideoTracks();
169
+ videoTracks.forEach(track => {
170
+ track.enabled = enabled;
171
+ });
172
+ this.videoEnabled = enabled;
173
+ this.emit('videoToggled', { enabled });
174
+ }
175
+
176
+ /**
177
+ * 오디오 상태 설정
178
+ * @param {boolean} enabled
179
+ */
180
+ setAudioEnabled(enabled) {
181
+ if (!this.localStream) {
182
+ this.logger.warn('No local stream available');
183
+ return;
184
+ }
185
+
186
+ const audioTracks = this.localStream.getAudioTracks();
187
+ audioTracks.forEach(track => {
188
+ track.enabled = enabled;
189
+ });
190
+ this.audioEnabled = enabled;
191
+ this.emit('audioToggled', { enabled });
192
+ }
193
+
194
+ /**
195
+ * 로컬 스트림 가져오기
196
+ * @returns {MediaStream|null}
197
+ */
198
+ getLocalStream() {
199
+ return this.localStream;
200
+ }
201
+
202
+ /**
203
+ * 화면 공유 스트림 가져오기
204
+ * @returns {MediaStream|null}
205
+ */
206
+ getScreenStream() {
207
+ return this.screenStream;
208
+ }
209
+
210
+ /**
211
+ * 현재 비디오 상태
212
+ * @returns {boolean}
213
+ */
214
+ isVideoEnabled() {
215
+ return this.videoEnabled;
216
+ }
217
+
218
+ /**
219
+ * 현재 오디오 상태
220
+ * @returns {boolean}
221
+ */
222
+ isAudioEnabled() {
223
+ return this.audioEnabled;
224
+ }
225
+
226
+ /**
227
+ * 모든 트랙 중지
228
+ */
229
+ stopAll() {
230
+ if (this.localStream) {
231
+ this.localStream.getTracks().forEach(track => track.stop());
232
+ this.localStream = null;
233
+ this.emit('streamStopped', {});
234
+ this.logger.info('All media tracks stopped');
235
+ }
236
+
237
+ if (this.screenStream) {
238
+ this.screenStream.getTracks().forEach(track => track.stop());
239
+ this.screenStream = null;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * 특정 트랙 교체 (내부용 - switchDevice에서 호출)
245
+ * @private
246
+ * @param {MediaStreamTrack} oldTrack
247
+ * @param {MediaStreamTrack} newTrack
248
+ */
249
+ replaceTrack(oldTrack, newTrack) {
250
+ if (!this.localStream) {
251
+ this.logger.warn('No local stream available');
252
+ return;
253
+ }
254
+
255
+ this.localStream.removeTrack(oldTrack);
256
+ this.localStream.addTrack(newTrack);
257
+ oldTrack.stop();
258
+
259
+ this.emit('trackReplaced', { oldTrack, newTrack });
260
+ this.logger.debug(`Track replaced: ${oldTrack.kind}`);
261
+ }
262
+
263
+ /**
264
+ * 특정 종류의 트랙 가져오기
265
+ * @param {string} kind - 'video' or 'audio'
266
+ * @returns {MediaStreamTrack|null}
267
+ */
268
+ getTrack(kind) {
269
+ if (!this.localStream) return null;
270
+
271
+ const tracks = kind === 'video'
272
+ ? this.localStream.getVideoTracks()
273
+ : this.localStream.getAudioTracks();
274
+
275
+ return tracks.length > 0 ? tracks[0] : null;
276
+ }
277
+
278
+ /**
279
+ * 디바이스 목록 가져오기
280
+ * @returns {Promise<Object>}
281
+ */
282
+ async getDevices() {
283
+ try {
284
+ const devices = await navigator.mediaDevices.enumerateDevices();
285
+
286
+ return {
287
+ videoInputs: devices.filter(d => d.kind === 'videoinput'),
288
+ audioInputs: devices.filter(d => d.kind === 'audioinput'),
289
+ audioOutputs: devices.filter(d => d.kind === 'audiooutput')
290
+ };
291
+ } catch (error) {
292
+ this.logger.error('Failed to enumerate devices:', error);
293
+ this.emit('error', {
294
+ type: ErrorTypes.ENUMERATE_DEVICES_FAILED,
295
+ message: error.message,
296
+ error
297
+ });
298
+ throw error;
299
+ }
300
+ }
301
+
302
+ /**
303
+ * 특정 디바이스로 전환
304
+ * @param {string} deviceId - 디바이스 ID
305
+ * @param {string} kind - 'video' or 'audio'
306
+ * @returns {Promise<MediaStreamTrack>}
307
+ * @throws {Error} 스트림이 없거나 디바이스 전환 실패 시
308
+ */
309
+ async switchDevice(deviceId, kind) {
310
+ if (!this.localStream) {
311
+ throw new Error('No local stream available');
312
+ }
313
+
314
+ try {
315
+ const constraints = kind === 'video'
316
+ ? { video: { deviceId: { exact: deviceId } }, audio: false }
317
+ : { video: false, audio: { deviceId: { exact: deviceId } } };
318
+
319
+ const newStream = await navigator.mediaDevices.getUserMedia(constraints);
320
+ const newTrack = newStream.getTracks()[0];
321
+
322
+ const oldTrack = kind === 'video'
323
+ ? this.localStream.getVideoTracks()[0]
324
+ : this.localStream.getAudioTracks()[0];
325
+
326
+ if (oldTrack) {
327
+ this.replaceTrack(oldTrack, newTrack);
328
+
329
+ this.emit('deviceSwitched', {
330
+ kind,
331
+ deviceId,
332
+ newTrack
333
+ });
334
+
335
+ this.logger.info(`${kind} device switched to ${deviceId}`);
336
+ }
337
+
338
+ return newTrack;
339
+
340
+ } catch (error) {
341
+ this.logger.error(`Failed to switch ${kind} device:`, error);
342
+ this.emit('error', {
343
+ type: ErrorTypes.DEVICE_SWITCH_FAILED,
344
+ message: error.message,
345
+ error
346
+ });
347
+ throw error;
348
+ }
349
+ }
350
+
351
+ /**
352
+ * 디바이스 변경 감지 시작
353
+ */
354
+ startDeviceChangeDetection() {
355
+ if (this._deviceChangeHandler) {
356
+ return;
357
+ }
358
+
359
+ this._deviceChangeHandler = async () => {
360
+ try {
361
+ const devices = await this.getDevices();
362
+ this.emit('deviceChange', devices);
363
+ this.logger.debug('Device change detected');
364
+ } catch (error) {
365
+ this.logger.warn('Failed to get devices on change:', error);
366
+ }
367
+ };
368
+
369
+ navigator.mediaDevices.addEventListener('devicechange', this._deviceChangeHandler);
370
+ this.logger.debug('Device change detection started');
371
+ }
372
+
373
+ /**
374
+ * 디바이스 변경 감지 중지
375
+ */
376
+ stopDeviceChangeDetection() {
377
+ if (this._deviceChangeHandler) {
378
+ navigator.mediaDevices.removeEventListener('devicechange', this._deviceChangeHandler);
379
+ this._deviceChangeHandler = null;
380
+ this.logger.debug('Device change detection stopped');
381
+ }
382
+ }
383
+
384
+ /**
385
+ * 스트림 정보
386
+ * @returns {Object}
387
+ */
388
+ getStreamInfo() {
389
+ if (!this.localStream) {
390
+ return {
391
+ hasStream: false,
392
+ videoEnabled: this.videoEnabled,
393
+ audioEnabled: this.audioEnabled,
394
+ tracks: []
395
+ };
396
+ }
397
+
398
+ return {
399
+ hasStream: true,
400
+ videoEnabled: this.videoEnabled,
401
+ audioEnabled: this.audioEnabled,
402
+ tracks: this.localStream.getTracks().map(track => ({
403
+ kind: track.kind,
404
+ id: track.id,
405
+ label: track.label,
406
+ enabled: track.enabled,
407
+ readyState: track.readyState,
408
+ muted: track.muted
409
+ }))
410
+ };
411
+ }
412
+
413
+ /**
414
+ * 비디오 해상도 변경
415
+ * @param {Object} constraints - { width, height, frameRate }
416
+ */
417
+ async applyVideoConstraints(constraints) {
418
+ if (!this.localStream) {
419
+ throw new Error('No local stream available');
420
+ }
421
+
422
+ const videoTrack = this.localStream.getVideoTracks()[0];
423
+ if (!videoTrack) {
424
+ throw new Error('No video track available');
425
+ }
426
+
427
+ try {
428
+ await videoTrack.applyConstraints(constraints);
429
+ this.emit('videoConstraintsApplied', { constraints });
430
+ this.logger.info('Video constraints applied:', constraints);
431
+ } catch (error) {
432
+ this.logger.error('Failed to apply video constraints:', error);
433
+ throw error;
434
+ }
435
+ }
436
+
437
+ /**
438
+ * 현재 비디오 설정 가져오기
439
+ * @returns {MediaTrackSettings|null}
440
+ */
441
+ getVideoSettings() {
442
+ if (!this.localStream) return null;
443
+
444
+ const videoTrack = this.localStream.getVideoTracks()[0];
445
+ return videoTrack ? videoTrack.getSettings() : null;
446
+ }
447
+
448
+ /**
449
+ * 현재 오디오 설정 가져오기
450
+ * @returns {MediaTrackSettings|null}
451
+ */
452
+ getAudioSettings() {
453
+ if (!this.localStream) return null;
454
+
455
+ const audioTrack = this.localStream.getAudioTracks()[0];
456
+ return audioTrack ? audioTrack.getSettings() : null;
457
+ }
458
+
459
+ /**
460
+ * 로그 레벨 설정
461
+ * @param {number} level
462
+ */
463
+ setLogLevel(level) {
464
+ this.logger.setLevel(level);
465
+ }
466
+
467
+ /**
468
+ * 리소스 정리
469
+ */
470
+ destroy() {
471
+ this.stopAll();
472
+ this.stopDeviceChangeDetection();
473
+ this.removeAllListeners();
474
+ this.logger.info('MediaStreamManager destroyed');
475
+ }
476
+ }
477
+
478
+ export default MediaStreamManager;