network.js (15328B)
1 window.URL = window.URL || window.webkitURL; 2 window.isRtcSupported = !!(window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection); 3 4 class ServerConnection { 5 6 constructor() { 7 this._connect(); 8 Events.on('beforeunload', e => this._disconnect()); 9 Events.on('pagehide', e => this._disconnect()); 10 document.addEventListener('visibilitychange', e => this._onVisibilityChange()); 11 } 12 13 _connect() { 14 clearTimeout(this._reconnectTimer); 15 if (this._isConnected() || this._isConnecting()) return; 16 const ws = new WebSocket(this._endpoint()); 17 ws.binaryType = 'arraybuffer'; 18 ws.onopen = e => console.log('WS: server connected'); 19 ws.onmessage = e => this._onMessage(e.data); 20 ws.onclose = e => this._onDisconnect(); 21 ws.onerror = e => console.error(e); 22 this._socket = ws; 23 } 24 25 _onMessage(msg) { 26 msg = JSON.parse(msg); 27 console.log('WS:', msg); 28 switch (msg.type) { 29 case 'peers': 30 Events.fire('peers', msg.peers); 31 break; 32 case 'peer-joined': 33 Events.fire('peer-joined', msg.peer); 34 break; 35 case 'peer-left': 36 Events.fire('peer-left', msg.peerId); 37 break; 38 case 'signal': 39 Events.fire('signal', msg); 40 break; 41 case 'ping': 42 this.send({ type: 'pong' }); 43 break; 44 case 'display-name': 45 Events.fire('display-name', msg); 46 break; 47 default: 48 console.error('WS: unkown message type', msg); 49 } 50 } 51 52 send(message) { 53 if (!this._isConnected()) return; 54 this._socket.send(JSON.stringify(message)); 55 } 56 57 _endpoint() { 58 // hack to detect if deployment or development environment 59 const protocol = location.protocol.startsWith('https') ? 'wss' : 'ws'; 60 const webrtc = window.isRtcSupported ? '/webrtc' : '/fallback'; 61 const url = protocol + '://' + location.host + location.pathname + 'server' + webrtc; 62 return url; 63 } 64 65 _disconnect() { 66 this.send({ type: 'disconnect' }); 67 this._socket.onclose = null; 68 this._socket.close(); 69 } 70 71 _onDisconnect() { 72 console.log('WS: server disconnected'); 73 Events.fire('notify-user', 'Connection lost. Retry in 5 seconds...'); 74 clearTimeout(this._reconnectTimer); 75 this._reconnectTimer = setTimeout(_ => this._connect(), 5000); 76 } 77 78 _onVisibilityChange() { 79 if (document.hidden) return; 80 this._connect(); 81 } 82 83 _isConnected() { 84 return this._socket && this._socket.readyState === this._socket.OPEN; 85 } 86 87 _isConnecting() { 88 return this._socket && this._socket.readyState === this._socket.CONNECTING; 89 } 90 } 91 92 class Peer { 93 94 constructor(serverConnection, peerId) { 95 this._server = serverConnection; 96 this._peerId = peerId; 97 this._filesQueue = []; 98 this._busy = false; 99 } 100 101 sendJSON(message) { 102 this._send(JSON.stringify(message)); 103 } 104 105 sendFiles(files) { 106 for (let i = 0; i < files.length; i++) { 107 this._filesQueue.push(files[i]); 108 } 109 if (this._busy) return; 110 this._dequeueFile(); 111 } 112 113 _dequeueFile() { 114 if (!this._filesQueue.length) return; 115 this._busy = true; 116 const file = this._filesQueue.shift(); 117 this._sendFile(file); 118 } 119 120 _sendFile(file) { 121 this.sendJSON({ 122 type: 'header', 123 name: file.name, 124 mime: file.type, 125 size: file.size 126 }); 127 this._chunker = new FileChunker(file, 128 chunk => this._send(chunk), 129 offset => this._onPartitionEnd(offset)); 130 this._chunker.nextPartition(); 131 } 132 133 _onPartitionEnd(offset) { 134 this.sendJSON({ type: 'partition', offset: offset }); 135 } 136 137 _onReceivedPartitionEnd(offset) { 138 this.sendJSON({ type: 'partition-received', offset: offset }); 139 } 140 141 _sendNextPartition() { 142 if (!this._chunker || this._chunker.isFileEnd()) return; 143 this._chunker.nextPartition(); 144 } 145 146 _sendProgress(progress) { 147 this.sendJSON({ type: 'progress', progress: progress }); 148 } 149 150 _onMessage(message) { 151 if (typeof message !== 'string') { 152 this._onChunkReceived(message); 153 return; 154 } 155 message = JSON.parse(message); 156 console.log('RTC:', message); 157 switch (message.type) { 158 case 'header': 159 this._onFileHeader(message); 160 break; 161 case 'partition': 162 this._onReceivedPartitionEnd(message); 163 break; 164 case 'partition-received': 165 this._sendNextPartition(); 166 break; 167 case 'progress': 168 this._onDownloadProgress(message.progress); 169 break; 170 case 'transfer-complete': 171 this._onTransferCompleted(); 172 break; 173 case 'text': 174 this._onTextReceived(message); 175 break; 176 } 177 } 178 179 _onFileHeader(header) { 180 this._lastProgress = 0; 181 this._digester = new FileDigester({ 182 name: header.name, 183 mime: header.mime, 184 size: header.size 185 }, file => this._onFileReceived(file)); 186 } 187 188 _onChunkReceived(chunk) { 189 if(!chunk.byteLength) return; 190 191 this._digester.unchunk(chunk); 192 const progress = this._digester.progress; 193 this._onDownloadProgress(progress); 194 195 // occasionally notify sender about our progress 196 if (progress - this._lastProgress < 0.01) return; 197 this._lastProgress = progress; 198 this._sendProgress(progress); 199 } 200 201 _onDownloadProgress(progress) { 202 Events.fire('file-progress', { sender: this._peerId, progress: progress }); 203 } 204 205 _onFileReceived(proxyFile) { 206 Events.fire('file-received', proxyFile); 207 this.sendJSON({ type: 'transfer-complete' }); 208 } 209 210 _onTransferCompleted() { 211 this._onDownloadProgress(1); 212 this._reader = null; 213 this._busy = false; 214 this._dequeueFile(); 215 Events.fire('notify-user', 'File transfer completed.'); 216 } 217 218 sendText(text) { 219 const unescaped = btoa(unescape(encodeURIComponent(text))); 220 this.sendJSON({ type: 'text', text: unescaped }); 221 } 222 223 _onTextReceived(message) { 224 const escaped = decodeURIComponent(escape(atob(message.text))); 225 Events.fire('text-received', { text: escaped, sender: this._peerId }); 226 } 227 } 228 229 class RTCPeer extends Peer { 230 231 constructor(serverConnection, peerId) { 232 super(serverConnection, peerId); 233 if (!peerId) return; // we will listen for a caller 234 this._connect(peerId, true); 235 } 236 237 _connect(peerId, isCaller) { 238 if (!this._conn) this._openConnection(peerId, isCaller); 239 240 if (isCaller) { 241 this._openChannel(); 242 } else { 243 this._conn.ondatachannel = e => this._onChannelOpened(e); 244 } 245 } 246 247 _openConnection(peerId, isCaller) { 248 this._isCaller = isCaller; 249 this._peerId = peerId; 250 this._conn = new RTCPeerConnection(RTCPeer.config); 251 this._conn.onicecandidate = e => this._onIceCandidate(e); 252 this._conn.onconnectionstatechange = e => this._onConnectionStateChange(e); 253 this._conn.oniceconnectionstatechange = e => this._onIceConnectionStateChange(e); 254 } 255 256 _openChannel() { 257 const channel = this._conn.createDataChannel('data-channel', { 258 ordered: true, 259 reliable: true // Obsolete. See https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel/reliable 260 }); 261 channel.binaryType = 'arraybuffer'; 262 channel.onopen = e => this._onChannelOpened(e); 263 this._conn.createOffer().then(d => this._onDescription(d)).catch(e => this._onError(e)); 264 } 265 266 _onDescription(description) { 267 // description.sdp = description.sdp.replace('b=AS:30', 'b=AS:1638400'); 268 this._conn.setLocalDescription(description) 269 .then(_ => this._sendSignal({ sdp: description })) 270 .catch(e => this._onError(e)); 271 } 272 273 _onIceCandidate(event) { 274 if (!event.candidate) return; 275 this._sendSignal({ ice: event.candidate }); 276 } 277 278 onServerMessage(message) { 279 if (!this._conn) this._connect(message.sender, false); 280 281 if (message.sdp) { 282 this._conn.setRemoteDescription(new RTCSessionDescription(message.sdp)) 283 .then( _ => { 284 if (message.sdp.type === 'offer') { 285 return this._conn.createAnswer() 286 .then(d => this._onDescription(d)); 287 } 288 }) 289 .catch(e => this._onError(e)); 290 } else if (message.ice) { 291 this._conn.addIceCandidate(new RTCIceCandidate(message.ice)); 292 } 293 } 294 295 _onChannelOpened(event) { 296 console.log('RTC: channel opened with', this._peerId); 297 const channel = event.channel || event.target; 298 channel.onmessage = e => this._onMessage(e.data); 299 channel.onclose = e => this._onChannelClosed(); 300 this._channel = channel; 301 } 302 303 _onChannelClosed() { 304 console.log('RTC: channel closed', this._peerId); 305 if (!this.isCaller) return; 306 this._connect(this._peerId, true); // reopen the channel 307 } 308 309 _onConnectionStateChange(e) { 310 console.log('RTC: state changed:', this._conn.connectionState); 311 switch (this._conn.connectionState) { 312 case 'disconnected': 313 this._onChannelClosed(); 314 break; 315 case 'failed': 316 this._conn = null; 317 this._onChannelClosed(); 318 break; 319 } 320 } 321 322 _onIceConnectionStateChange() { 323 switch (this._conn.iceConnectionState) { 324 case 'failed': 325 console.error('ICE Gathering failed'); 326 break; 327 default: 328 console.log('ICE Gathering', this._conn.iceConnectionState); 329 } 330 } 331 332 _onError(error) { 333 console.error(error); 334 } 335 336 _send(message) { 337 if (!this._channel) return this.refresh(); 338 this._channel.send(message); 339 } 340 341 _sendSignal(signal) { 342 signal.type = 'signal'; 343 signal.to = this._peerId; 344 this._server.send(signal); 345 } 346 347 refresh() { 348 // check if channel is open. otherwise create one 349 if (this._isConnected() || this._isConnecting()) return; 350 this._connect(this._peerId, this._isCaller); 351 } 352 353 _isConnected() { 354 return this._channel && this._channel.readyState === 'open'; 355 } 356 357 _isConnecting() { 358 return this._channel && this._channel.readyState === 'connecting'; 359 } 360 } 361 362 class PeersManager { 363 364 constructor(serverConnection) { 365 this.peers = {}; 366 this._server = serverConnection; 367 Events.on('signal', e => this._onMessage(e.detail)); 368 Events.on('peers', e => this._onPeers(e.detail)); 369 Events.on('files-selected', e => this._onFilesSelected(e.detail)); 370 Events.on('send-text', e => this._onSendText(e.detail)); 371 Events.on('peer-left', e => this._onPeerLeft(e.detail)); 372 } 373 374 _onMessage(message) { 375 if (!this.peers[message.sender]) { 376 this.peers[message.sender] = new RTCPeer(this._server); 377 } 378 this.peers[message.sender].onServerMessage(message); 379 } 380 381 _onPeers(peers) { 382 peers.forEach(peer => { 383 if (this.peers[peer.id]) { 384 this.peers[peer.id].refresh(); 385 return; 386 } 387 if (window.isRtcSupported && peer.rtcSupported) { 388 this.peers[peer.id] = new RTCPeer(this._server, peer.id); 389 } else { 390 this.peers[peer.id] = new WSPeer(this._server, peer.id); 391 } 392 }) 393 } 394 395 sendTo(peerId, message) { 396 this.peers[peerId].send(message); 397 } 398 399 _onFilesSelected(message) { 400 this.peers[message.to].sendFiles(message.files); 401 } 402 403 _onSendText(message) { 404 this.peers[message.to].sendText(message.text); 405 } 406 407 _onPeerLeft(peerId) { 408 const peer = this.peers[peerId]; 409 delete this.peers[peerId]; 410 if (!peer || !peer._peer) return; 411 peer._peer.close(); 412 } 413 414 } 415 416 class WSPeer { 417 _send(message) { 418 message.to = this._peerId; 419 this._server.send(message); 420 } 421 } 422 423 class FileChunker { 424 425 constructor(file, onChunk, onPartitionEnd) { 426 this._chunkSize = 64000; // 64 KB 427 this._maxPartitionSize = 1e6; // 1 MB 428 this._offset = 0; 429 this._partitionSize = 0; 430 this._file = file; 431 this._onChunk = onChunk; 432 this._onPartitionEnd = onPartitionEnd; 433 this._reader = new FileReader(); 434 this._reader.addEventListener('load', e => this._onChunkRead(e.target.result)); 435 } 436 437 nextPartition() { 438 this._partitionSize = 0; 439 this._readChunk(); 440 } 441 442 _readChunk() { 443 const chunk = this._file.slice(this._offset, this._offset + this._chunkSize); 444 this._reader.readAsArrayBuffer(chunk); 445 } 446 447 _onChunkRead(chunk) { 448 this._offset += chunk.byteLength; 449 this._partitionSize += chunk.byteLength; 450 this._onChunk(chunk); 451 if (this._isPartitionEnd() || this.isFileEnd()) { 452 this._onPartitionEnd(this._offset); 453 return; 454 } 455 this._readChunk(); 456 } 457 458 repeatPartition() { 459 this._offset -= this._partitionSize; 460 this._nextPartition(); 461 } 462 463 _isPartitionEnd() { 464 return this._partitionSize >= this._maxPartitionSize; 465 } 466 467 isFileEnd() { 468 return this._offset >= this._file.size; 469 } 470 471 get progress() { 472 return this._offset / this._file.size; 473 } 474 } 475 476 class FileDigester { 477 478 constructor(meta, callback) { 479 this._buffer = []; 480 this._bytesReceived = 0; 481 this._size = meta.size; 482 this._mime = meta.mime || 'application/octet-stream'; 483 this._name = meta.name; 484 this._callback = callback; 485 } 486 487 unchunk(chunk) { 488 this._buffer.push(chunk); 489 this._bytesReceived += chunk.byteLength || chunk.size; 490 const totalChunks = this._buffer.length; 491 this.progress = this._bytesReceived / this._size; 492 if (isNaN(this.progress)) this.progress = 1 493 494 if (this._bytesReceived < this._size) return; 495 // we are done 496 let blob = new Blob(this._buffer, { type: this._mime }); 497 this._callback({ 498 name: this._name, 499 mime: this._mime, 500 size: this._size, 501 blob: blob 502 }); 503 } 504 505 } 506 507 class Events { 508 static fire(type, detail) { 509 window.dispatchEvent(new CustomEvent(type, { detail: detail })); 510 } 511 512 static on(type, callback) { 513 return window.addEventListener(type, callback, false); 514 } 515 } 516 517 518 RTCPeer.config = { 519 'sdpSemantics': 'unified-plan', 520 'iceServers': [{ 521 urls: 'stun:stun.l.google.com:19302' 522 }] 523 }