@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,467 @@
1
+ /**
2
+ * PeerConnectionManager
3
+ * WebRTC Peer 연결 관리 (Perfect Negotiation 패턴)
4
+ */
5
+
6
+ import EventEmitter from '../utils/EventEmitter.js';
7
+ import Logger from '../utils/Logger.js';
8
+ import { ErrorTypes, DefaultConfig, LogLevel } from '../constants.js';
9
+
10
+ class PeerConnectionManager extends EventEmitter {
11
+ /**
12
+ * @param {Object} options
13
+ * @param {Object[]} [options.iceServers] - ICE 서버 설정
14
+ * @param {number} [options.logLevel] - 로그 레벨
15
+ */
16
+ constructor(options = {}) {
17
+ super();
18
+
19
+ this.iceServers = options.iceServers || DefaultConfig.iceServers;
20
+ this.logger = new Logger(options.logLevel || LogLevel.WARN, 'PeerConnectionManager');
21
+
22
+ // 피어 연결 맵: peerId -> { connection, polite, makingOffer, ignoreOffer }
23
+ this.peers = new Map();
24
+
25
+ // 로컬 스트림
26
+ this.localStream = null;
27
+ }
28
+
29
+ /**
30
+ * 로컬 스트림 설정
31
+ * @param {MediaStream} stream
32
+ */
33
+ setLocalStream(stream) {
34
+ this.localStream = stream;
35
+
36
+ // 기존 연결에 트랙 추가
37
+ this.peers.forEach((peer) => {
38
+ this._addTracksToConnection(peer.connection);
39
+ });
40
+ }
41
+
42
+ /**
43
+ * ICE 서버 설정
44
+ * @param {Object[]} servers
45
+ */
46
+ setIceServers(servers) {
47
+ this.iceServers = servers;
48
+ }
49
+
50
+ /**
51
+ * 피어 연결 생성
52
+ * @param {string} peerId - 피어 ID
53
+ * @param {boolean} polite - Polite 피어 여부 (Perfect Negotiation)
54
+ * @param {Object} [options] - 추가 옵션
55
+ * @param {boolean} [options.skipAutoNegotiation=false] - 자동 negotiation 건너뛰기
56
+ * @returns {RTCPeerConnection}
57
+ */
58
+ createPeerConnection(peerId, polite = false, options = {}) {
59
+ if (this.peers.has(peerId)) {
60
+ this.logger.warn(`Peer connection already exists: ${peerId}`);
61
+ return this.peers.get(peerId).connection;
62
+ }
63
+
64
+ const config = {
65
+ iceServers: this.iceServers,
66
+ iceCandidatePoolSize: 10
67
+ };
68
+
69
+ const connection = new RTCPeerConnection(config);
70
+
71
+ const peerState = {
72
+ connection,
73
+ polite,
74
+ makingOffer: false,
75
+ ignoreOffer: false,
76
+ isSettingRemoteAnswerPending: false,
77
+ skipAutoNegotiation: options.skipAutoNegotiation || false
78
+ };
79
+
80
+ this.peers.set(peerId, peerState);
81
+
82
+ // 이벤트 핸들러 설정
83
+ this._setupConnectionHandlers(peerId, connection, peerState);
84
+
85
+ // 로컬 트랙 추가
86
+ if (this.localStream) {
87
+ this._addTracksToConnection(connection);
88
+ }
89
+
90
+ this.logger.info(`Peer connection created: ${peerId} (polite: ${polite}, skipAutoNegotiation: ${peerState.skipAutoNegotiation})`);
91
+ return connection;
92
+ }
93
+
94
+ /**
95
+ * 연결 이벤트 핸들러 설정
96
+ * @private
97
+ * @param {string} peerId - 피어 ID
98
+ * @param {RTCPeerConnection} connection - 피어 연결
99
+ * @param {Object} peerState - 피어 상태 객체
100
+ */
101
+ _setupConnectionHandlers(peerId, connection, peerState) {
102
+ // ICE Candidate 이벤트
103
+ connection.onicecandidate = (event) => {
104
+ if (event.candidate) {
105
+ this.emit('iceCandidate', {
106
+ peerId,
107
+ candidate: event.candidate
108
+ });
109
+ }
110
+ };
111
+
112
+ // ICE 연결 상태 변경
113
+ connection.oniceconnectionstatechange = () => {
114
+ this.logger.debug(`ICE connection state (${peerId}): ${connection.iceConnectionState}`);
115
+
116
+ this.emit('iceConnectionStateChange', {
117
+ peerId,
118
+ state: connection.iceConnectionState
119
+ });
120
+
121
+ if (connection.iceConnectionState === 'failed') {
122
+ this.emit('error', {
123
+ type: ErrorTypes.ICE_CONNECTION_FAILED,
124
+ peerId,
125
+ message: 'ICE connection failed'
126
+ });
127
+ }
128
+ };
129
+
130
+ // 연결 상태 변경
131
+ connection.onconnectionstatechange = () => {
132
+ this.logger.debug(`Connection state (${peerId}): ${connection.connectionState}`);
133
+
134
+ this.emit('connectionStateChange', {
135
+ peerId,
136
+ state: connection.connectionState
137
+ });
138
+
139
+ if (connection.connectionState === 'connected') {
140
+ this.emit('peerConnected', { peerId });
141
+ } else if (connection.connectionState === 'disconnected' || connection.connectionState === 'failed') {
142
+ this.emit('peerDisconnected', { peerId });
143
+ }
144
+ };
145
+
146
+ // 협상 필요 (Perfect Negotiation)
147
+ connection.onnegotiationneeded = async () => {
148
+ // 1:1 통화에서 수동으로 offer를 생성할 때는 자동 negotiation 건너뛰기
149
+ if (peerState.skipAutoNegotiation) {
150
+ this.logger.debug(`Skipping auto negotiation for ${peerId} (manual offer will be sent)`);
151
+ return;
152
+ }
153
+
154
+ try {
155
+ peerState.makingOffer = true;
156
+ await connection.setLocalDescription();
157
+
158
+ this.emit('negotiationNeeded', {
159
+ peerId,
160
+ description: connection.localDescription
161
+ });
162
+
163
+ } catch (error) {
164
+ this.logger.error(`Negotiation error (${peerId}):`, error);
165
+ } finally {
166
+ peerState.makingOffer = false;
167
+ }
168
+ };
169
+
170
+ // 원격 트랙 수신
171
+ connection.ontrack = (event) => {
172
+ this.logger.info(`Remote track received from ${peerId}:`, event.track.kind);
173
+
174
+ this.emit('remoteTrack', {
175
+ peerId,
176
+ track: event.track,
177
+ streams: event.streams
178
+ });
179
+ };
180
+
181
+ // 데이터 채널 수신
182
+ connection.ondatachannel = (event) => {
183
+ this.logger.info(`Data channel received from ${peerId}`);
184
+ this.emit('dataChannel', {
185
+ peerId,
186
+ channel: event.channel
187
+ });
188
+ };
189
+ }
190
+
191
+ /**
192
+ * 연결에 트랙 추가
193
+ * @private
194
+ * @param {RTCPeerConnection} connection - 피어 연결
195
+ */
196
+ _addTracksToConnection(connection) {
197
+ if (!this.localStream) return;
198
+
199
+ const existingSenders = connection.getSenders();
200
+
201
+ this.localStream.getTracks().forEach(track => {
202
+ // 이미 추가된 트랙인지 확인
203
+ const existingSender = existingSenders.find(sender =>
204
+ sender.track && sender.track.kind === track.kind
205
+ );
206
+
207
+ if (!existingSender) {
208
+ connection.addTrack(track, this.localStream);
209
+ }
210
+ });
211
+ }
212
+
213
+ /**
214
+ * Offer 생성 및 전송
215
+ * @param {string} peerId
216
+ * @returns {Promise<RTCSessionDescription>}
217
+ */
218
+ async createOffer(peerId) {
219
+ const peer = this.peers.get(peerId);
220
+ if (!peer) {
221
+ throw new Error(`Peer not found: ${peerId}`);
222
+ }
223
+
224
+ try {
225
+ const offer = await peer.connection.createOffer();
226
+ await peer.connection.setLocalDescription(offer);
227
+
228
+ this.logger.debug(`Offer created for ${peerId}`);
229
+ return peer.connection.localDescription;
230
+
231
+ } catch (error) {
232
+ this.logger.error(`Failed to create offer for ${peerId}:`, error);
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Answer 생성
239
+ * @param {string} peerId
240
+ * @returns {Promise<RTCSessionDescription>}
241
+ */
242
+ async createAnswer(peerId) {
243
+ const peer = this.peers.get(peerId);
244
+ if (!peer) {
245
+ throw new Error(`Peer not found: ${peerId}`);
246
+ }
247
+
248
+ try {
249
+ const answer = await peer.connection.createAnswer();
250
+ await peer.connection.setLocalDescription(answer);
251
+
252
+ this.logger.debug(`Answer created for ${peerId}`);
253
+ return peer.connection.localDescription;
254
+
255
+ } catch (error) {
256
+ this.logger.error(`Failed to create answer for ${peerId}:`, error);
257
+ throw error;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * 원격 Description 설정 (Perfect Negotiation)
263
+ * @param {string} peerId
264
+ * @param {RTCSessionDescription} description
265
+ */
266
+ async handleRemoteDescription(peerId, description) {
267
+ const peer = this.peers.get(peerId);
268
+ if (!peer) {
269
+ throw new Error(`Peer not found: ${peerId}`);
270
+ }
271
+
272
+ const { connection, polite, makingOffer } = peer;
273
+
274
+ // Perfect Negotiation: offer 충돌 감지
275
+ const offerCollision = description.type === 'offer' &&
276
+ (makingOffer || connection.signalingState !== 'stable');
277
+
278
+ // Impolite는 충돌 시 상대 offer 무시
279
+ peer.ignoreOffer = !polite && offerCollision;
280
+
281
+ if (peer.ignoreOffer) {
282
+ this.logger.debug(`Ignoring offer collision from ${peerId} (I am impolite)`);
283
+ return;
284
+ }
285
+
286
+ try {
287
+ // Perfect Negotiation: Polite가 충돌 시 자신의 offer를 roll back
288
+ if (offerCollision && polite) {
289
+ this.logger.debug(`Offer collision detected, rolling back local offer for ${peerId} (I am polite)`);
290
+ await connection.setLocalDescription({ type: 'rollback' });
291
+ }
292
+
293
+ peer.isSettingRemoteAnswerPending = description.type === 'answer';
294
+ await connection.setRemoteDescription(description);
295
+ peer.isSettingRemoteAnswerPending = false;
296
+
297
+ this.logger.debug(`Remote ${description.type} set for ${peerId}`);
298
+
299
+ // Offer를 받았으면 Answer 생성
300
+ if (description.type === 'offer') {
301
+ const answer = await this.createAnswer(peerId);
302
+ this.emit('answerCreated', { peerId, answer });
303
+ }
304
+
305
+ } catch (error) {
306
+ this.logger.error(`Failed to set remote description for ${peerId}:`, error);
307
+ throw error;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * ICE Candidate 추가
313
+ * @param {string} peerId
314
+ * @param {RTCIceCandidate} candidate
315
+ * @returns {Promise<boolean>} 추가 성공 여부 (false면 나중에 다시 시도 필요)
316
+ */
317
+ async addIceCandidate(peerId, candidate) {
318
+ const peer = this.peers.get(peerId);
319
+ if (!peer) {
320
+ this.logger.warn(`Peer not found for ICE candidate: ${peerId}`);
321
+ return false;
322
+ }
323
+
324
+ // remoteDescription이 설정되지 않았으면 추가 불가
325
+ if (!peer.connection.remoteDescription) {
326
+ this.logger.debug(`Remote description not set yet for ${peerId}, ICE candidate queued`);
327
+ return false;
328
+ }
329
+
330
+ try {
331
+ await peer.connection.addIceCandidate(candidate);
332
+ this.logger.debug(`ICE candidate added for ${peerId}`);
333
+ return true;
334
+ } catch (error) {
335
+ if (!peer.ignoreOffer) {
336
+ this.logger.error(`Failed to add ICE candidate for ${peerId}:`, error);
337
+ }
338
+ return false;
339
+ }
340
+ }
341
+
342
+ /**
343
+ * 특정 피어 연결 가져오기
344
+ * @param {string} peerId
345
+ * @returns {RTCPeerConnection|null}
346
+ */
347
+ getPeerConnection(peerId) {
348
+ const peer = this.peers.get(peerId);
349
+ return peer ? peer.connection : null;
350
+ }
351
+
352
+ /**
353
+ * 모든 피어 ID 목록
354
+ * @returns {string[]}
355
+ */
356
+ getPeerIds() {
357
+ return Array.from(this.peers.keys());
358
+ }
359
+
360
+ /**
361
+ * 피어 연결 종료
362
+ * @param {string} peerId
363
+ */
364
+ closePeerConnection(peerId) {
365
+ const peer = this.peers.get(peerId);
366
+ if (!peer) {
367
+ return;
368
+ }
369
+
370
+ peer.connection.close();
371
+ this.peers.delete(peerId);
372
+
373
+ this.emit('peerClosed', { peerId });
374
+ this.logger.info(`Peer connection closed: ${peerId}`);
375
+ }
376
+
377
+ /**
378
+ * 모든 피어 연결 종료
379
+ */
380
+ closeAllPeerConnections() {
381
+ this.peers.forEach((peer, peerId) => {
382
+ peer.connection.close();
383
+ this.logger.debug(`Peer connection closed: ${peerId}`);
384
+ });
385
+ this.peers.clear();
386
+ this.emit('allPeersClosed', {});
387
+ }
388
+
389
+ /**
390
+ * 트랙 교체
391
+ * @param {MediaStreamTrack} oldTrack - 교체할 기존 트랙
392
+ * @param {MediaStreamTrack} newTrack - 새 트랙
393
+ * @returns {Promise<void>}
394
+ */
395
+ async replaceTrack(oldTrack, newTrack) {
396
+ const promises = [];
397
+
398
+ this.peers.forEach((peer, peerId) => {
399
+ const sender = peer.connection.getSenders().find(s =>
400
+ s.track && s.track.kind === oldTrack.kind
401
+ );
402
+
403
+ if (sender) {
404
+ promises.push(
405
+ sender.replaceTrack(newTrack)
406
+ .then(() => this.logger.debug(`Track replaced for ${peerId}`))
407
+ .catch(error => this.logger.error(`Failed to replace track for ${peerId}:`, error))
408
+ );
409
+ }
410
+ });
411
+
412
+ await Promise.all(promises);
413
+ }
414
+
415
+ /**
416
+ * 연결 통계 가져오기
417
+ * @param {string} peerId
418
+ * @returns {Promise<RTCStatsReport|null>}
419
+ */
420
+ async getStats(peerId) {
421
+ const peer = this.peers.get(peerId);
422
+ if (!peer) {
423
+ return null;
424
+ }
425
+
426
+ return peer.connection.getStats();
427
+ }
428
+
429
+ /**
430
+ * 모든 연결 상태 요약
431
+ * @returns {Object}
432
+ */
433
+ getConnectionSummary() {
434
+ const summary = {};
435
+
436
+ this.peers.forEach((peer, peerId) => {
437
+ summary[peerId] = {
438
+ connectionState: peer.connection.connectionState,
439
+ iceConnectionState: peer.connection.iceConnectionState,
440
+ signalingState: peer.connection.signalingState,
441
+ polite: peer.polite
442
+ };
443
+ });
444
+
445
+ return summary;
446
+ }
447
+
448
+ /**
449
+ * 로그 레벨 설정
450
+ * @param {number} level
451
+ */
452
+ setLogLevel(level) {
453
+ this.logger.setLevel(level);
454
+ }
455
+
456
+ /**
457
+ * 리소스 정리
458
+ */
459
+ destroy() {
460
+ this.closeAllPeerConnections();
461
+ this.localStream = null;
462
+ this.removeAllListeners();
463
+ this.logger.info('PeerConnectionManager destroyed');
464
+ }
465
+ }
466
+
467
+ export default PeerConnectionManager;