@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.
- package/README.md +327 -0
- package/dist/index.d.ts +1771 -0
- package/dist/talkflow-sdk.esm.js +2 -0
- package/dist/talkflow-sdk.esm.js.map +1 -0
- package/dist/talkflow-sdk.standalone.js +2 -0
- package/dist/talkflow-sdk.standalone.js.map +1 -0
- package/dist/talkflow-sdk.umd.js +2 -0
- package/dist/talkflow-sdk.umd.js.map +1 -0
- package/package.json +51 -0
- package/src/TalkFlowClient.js +481 -0
- package/src/chat/ChatClient.js +2221 -0
- package/src/constants.js +411 -0
- package/src/core/ConnectionManager.js +517 -0
- package/src/index.js +97 -0
- package/src/push/PushManager.js +893 -0
- package/src/talkflow/delegates.js +112 -0
- package/src/talkflow/eventForwarding.js +93 -0
- package/src/talkflow/session.js +355 -0
- package/src/utils/ApiClient.js +305 -0
- package/src/utils/EventEmitter.js +113 -0
- package/src/utils/Logger.js +88 -0
- package/src/utils/jwtUtils.js +213 -0
- package/src/webrtc/MediaStreamManager.js +478 -0
- package/src/webrtc/PeerConnectionManager.js +467 -0
- package/src/webrtc/WebRTCClient.js +1041 -0
- package/types/index.d.ts +1771 -0
|
@@ -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;
|