1 // Copyright (c) 2020 by Juliusz Chroboczek. 2 3 // Permission is hereby granted, free of charge, to any person obtaining a copy 4 // of this software and associated documentation files (the "Software"), to deal 5 // in the Software without restriction, including without limitation the rights 6 // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 // copies of the Software, and to permit persons to whom the Software is 8 // furnished to do so, subject to the following conditions: 9 // 10 // The above copyright notice and this permission notice shall be included in 11 // all copies or substantial portions of the Software. 12 // 13 // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 // THE SOFTWARE. 20 21 'use strict'; 22 23 /** 24 * toHex formats an array as a hexadecimal string. 25 * 26 * @param {number[]|Uint8Array} array - the array to format 27 * @returns {string} - the hexadecimal representation of array 28 */ 29 function toHex(array) { 30 let a = new Uint8Array(array); 31 function hex(x) { 32 let h = x.toString(16); 33 if(h.length < 2) 34 h = '0' + h; 35 return h; 36 } 37 return a.reduce((x, y) => x + hex(y), ''); 38 } 39 40 /** 41 * newRandomId returns a random string of 32 hex digits (16 bytes). 42 * 43 * @returns {string} 44 */ 45 function newRandomId() { 46 let a = new Uint8Array(16); 47 crypto.getRandomValues(a); 48 return toHex(a); 49 } 50 51 let localIdCounter = 0; 52 53 /** 54 * newLocalId returns a string that is unique in this session. 55 * 56 * @returns {string} 57 */ 58 function newLocalId() { 59 let id = `${localIdCounter}` 60 localIdCounter++; 61 return id; 62 } 63 64 /** 65 * @typedef {Object} user 66 * @property {string} username 67 * @property {Array<string>} permissions 68 * @property {Object<string,any>} data 69 * @property {Object<string,Object<string,boolean>>} streams 70 */ 71 72 /** 73 * ServerConnection encapsulates a websocket connection to the server and 74 * all the associated streams. 75 * @constructor 76 */ 77 function ServerConnection() { 78 /** 79 * The id of this connection. 80 * 81 * @type {string} 82 * @const 83 */ 84 this.id = newRandomId(); 85 /** 86 * The group that we have joined, or null if we haven't joined yet. 87 * 88 * @type {string} 89 */ 90 this.group = null; 91 /** 92 * The username we joined as. 93 * 94 * @type {string} 95 */ 96 this.username = null; 97 /** 98 * The set of users in this group, including ourself. 99 * 100 * @type {Object<string,user>} 101 */ 102 this.users = {}; 103 /** 104 * The underlying websocket. 105 * 106 * @type {WebSocket} 107 */ 108 this.socket = null; 109 /** 110 * The negotiated protocol version. 111 * 112 * @type {string} 113 */ 114 this.version = null; 115 /** 116 * The set of all up streams, indexed by their id. 117 * 118 * @type {Object<string,Stream>} 119 */ 120 this.up = {}; 121 /** 122 * The set of all down streams, indexed by their id. 123 * 124 * @type {Object<string,Stream>} 125 */ 126 this.down = {}; 127 /** 128 * The ICE configuration used by all associated streams. 129 * 130 * @type {RTCConfiguration} 131 */ 132 this.rtcConfiguration = null; 133 /** 134 * The permissions granted to this connection. 135 * 136 * @type {Array<string>} 137 */ 138 this.permissions = []; 139 /** 140 * userdata is a convenient place to attach data to a ServerConnection. 141 * It is not used by the library. 142 * 143 * @type{Object<unknown,unknown>} 144 */ 145 this.userdata = {}; 146 147 /* Callbacks */ 148 149 /** 150 * onconnected is called when the connection has been established 151 * 152 * @type{(this: ServerConnection) => void} 153 */ 154 this.onconnected = null; 155 /** 156 * onclose is called when the connection is closed 157 * 158 * @type{(this: ServerConnection, code: number, reason: string) => void} 159 */ 160 this.onclose = null; 161 /** 162 * onpeerconnection is called before we establish a new peer connection. 163 * It may either return null, or a new RTCConfiguration that overrides 164 * the value obtained from the server. 165 * 166 * @type{(this: ServerConnection) => RTCConfiguration} 167 */ 168 this.onpeerconnection = null; 169 /** 170 * onuser is called whenever a user in the group changes. The users 171 * array has already been updated. 172 * 173 * @type{(this: ServerConnection, id: string, kind: string) => void} 174 */ 175 this.onuser = null; 176 /** 177 * onjoined is called whenever we join or leave a group or whenever the 178 * permissions we have in a group change. 179 * 180 * kind is one of 'join', 'fail', 'change' or 'leave'. 181 * 182 * @type{(this: ServerConnection, kind: string, group: string, permissions: Array<string>, status: Object<string,any>, data: Object<string,any>, error: string, message: string) => void} 183 */ 184 this.onjoined = null; 185 /** 186 * ondownstream is called whenever a new down stream is added. It 187 * should set up the stream's callbacks; actually setting up the UI 188 * should be done in the stream's ondowntrack callback. 189 * 190 * @type{(this: ServerConnection, stream: Stream) => void} 191 */ 192 this.ondownstream = null; 193 /** 194 * onchat is called whenever a new chat message is received. 195 * 196 * @type {(this: ServerConnection, id: string, dest: string, username: string, time: Date, privileged: boolean, history: boolean, kind: string, message: string) => void} 197 */ 198 this.onchat = null; 199 /** 200 * onusermessage is called when an application-specific message is 201 * received. Id is null when the message originated at the server, 202 * a user-id otherwise. 203 * 204 * 'kind' is typically one of 'error', 'warning', 'info' or 'mute'. If 205 * 'id' is non-null, 'privileged' indicates whether the message was 206 * sent by an operator. 207 * 208 * @type {(this: ServerConnection, id: string, dest: string, username: string, time: Date, privileged: boolean, kind: string, error: string, message: unknown) => void} 209 */ 210 this.onusermessage = null; 211 /** 212 * The set of files currently being transferred. 213 * 214 * @type {Object<string,TransferredFile>} 215 */ 216 this.transferredFiles = {}; 217 /** 218 * onfiletransfer is called whenever a peer offers a file transfer. 219 * 220 * If the transfer is accepted, it should set up the file transfer 221 * callbacks and return immediately. It may also throw an exception 222 * in order to reject the file transfer. 223 * 224 * @type {(this: ServerConnection, f: TransferredFile) => void} 225 */ 226 this.onfiletransfer = null; 227 } 228 229 /** 230 * @typedef {Object} message 231 * @property {string} type 232 * @property {Array<string>} [version] 233 * @property {string} [kind] 234 * @property {string} [error] 235 * @property {string} [id] 236 * @property {string} [replace] 237 * @property {string} [source] 238 * @property {string} [dest] 239 * @property {string} [username] 240 * @property {string} [password] 241 * @property {string} [token] 242 * @property {boolean} [privileged] 243 * @property {Array<string>} [permissions] 244 * @property {Object<string,any>} [status] 245 * @property {Object<string,any>} [data] 246 * @property {string} [group] 247 * @property {unknown} [value] 248 * @property {boolean} [noecho] 249 * @property {string|number} [time] 250 * @property {string} [sdp] 251 * @property {RTCIceCandidate} [candidate] 252 * @property {string} [label] 253 * @property {Object<string,Array<string>>|Array<string>} [request] 254 * @property {Object<string,any>} [rtcConfiguration] 255 */ 256 257 /** 258 * close forcibly closes a server connection. The onclose callback will 259 * be called when the connection is effectively closed. 260 */ 261 ServerConnection.prototype.close = function() { 262 this.socket && this.socket.close(1000, 'Close requested by client'); 263 this.socket = null; 264 }; 265 266 /** 267 * send sends a message to the server. 268 * @param {message} m - the message to send. 269 */ 270 ServerConnection.prototype.send = function(m) { 271 if(!this.socket || this.socket.readyState !== this.socket.OPEN) { 272 // send on a closed socket doesn't throw 273 throw(new Error('Connection is not open')); 274 } 275 return this.socket.send(JSON.stringify(m)); 276 }; 277 278 /** 279 * connect connects to the server. 280 * 281 * @param {string} url - The URL to connect to. 282 * @returns {Promise<ServerConnection>} 283 * @function 284 */ 285 ServerConnection.prototype.connect = async function(url) { 286 let sc = this; 287 if(sc.socket) { 288 sc.socket.close(1000, 'Reconnecting'); 289 sc.socket = null; 290 } 291 292 sc.socket = new WebSocket(url); 293 294 return await new Promise((resolve, reject) => { 295 this.socket.onerror = function(e) { 296 reject(e); 297 }; 298 this.socket.onopen = function(e) { 299 sc.send({ 300 type: 'handshake', 301 version: ['2'], 302 id: sc.id, 303 }); 304 if(sc.onconnected) 305 sc.onconnected.call(sc); 306 resolve(sc); 307 }; 308 this.socket.onclose = function(e) { 309 sc.permissions = []; 310 for(let id in sc.up) { 311 let c = sc.up[id]; 312 c.close(); 313 } 314 for(let id in sc.down) { 315 let c = sc.down[id]; 316 c.close(); 317 } 318 for(let id in sc.users) { 319 delete(sc.users[id]); 320 if(sc.onuser) 321 sc.onuser.call(sc, id, 'delete'); 322 } 323 if(sc.group && sc.onjoined) 324 sc.onjoined.call(sc, 'leave', sc.group, [], {}, {}, '', ''); 325 sc.group = null; 326 sc.username = null; 327 if(sc.onclose) 328 sc.onclose.call(sc, e.code, e.reason); 329 reject(new Error('websocket close ' + e.code + ' ' + e.reason)); 330 }; 331 this.socket.onmessage = function(e) { 332 let m = JSON.parse(e.data); 333 switch(m.type) { 334 case 'handshake': { 335 if((m.version instanceof Array) && m.version.includes('2')) { 336 sc.version = '2'; 337 } else { 338 sc.version = null; 339 console.error(`Unknown protocol version ${m.version}`); 340 throw new Error(`Unknown protocol version ${m.version}`); 341 } 342 break; 343 } 344 case 'offer': 345 sc.gotOffer(m.id, m.label, m.source, m.username, 346 m.sdp, m.replace); 347 break; 348 case 'answer': 349 sc.gotAnswer(m.id, m.sdp); 350 break; 351 case 'renegotiate': 352 sc.gotRenegotiate(m.id); 353 break; 354 case 'close': 355 sc.gotClose(m.id); 356 break; 357 case 'abort': 358 sc.gotAbort(m.id); 359 break; 360 case 'ice': 361 sc.gotRemoteIce(m.id, m.candidate); 362 break; 363 case 'joined': 364 if(m.kind === 'leave' || m.kind === 'fail') { 365 for(let id in sc.users) { 366 delete(sc.users[id]); 367 if(sc.onuser) 368 sc.onuser.call(sc, id, 'delete'); 369 } 370 sc.username = null; 371 sc.permissions = []; 372 sc.rtcConfiguration = null; 373 } else if(m.kind === 'join' || m.kind == 'change') { 374 if(m.kind === 'join' && sc.group) { 375 throw new Error('Joined multiple groups'); 376 } else if(m.kind === 'change' && m.group != sc.group) { 377 console.warn('join(change) for inconsistent group'); 378 break; 379 } 380 sc.group = m.group; 381 sc.username = m.username; 382 sc.permissions = m.permissions || []; 383 sc.rtcConfiguration = m.rtcConfiguration || null; 384 } 385 if(sc.onjoined) 386 sc.onjoined.call(sc, m.kind, m.group, 387 m.permissions || [], 388 m.status, m.data, 389 m.error || null, m.value || null); 390 break; 391 case 'user': 392 switch(m.kind) { 393 case 'add': 394 if(m.id in sc.users) 395 console.warn(`Duplicate user ${m.id} ${m.username}`); 396 sc.users[m.id] = { 397 username: m.username, 398 permissions: m.permissions || [], 399 data: m.data || {}, 400 streams: {}, 401 }; 402 break; 403 case 'change': 404 if(!(m.id in sc.users)) { 405 console.warn(`Unknown user ${m.id} ${m.username}`); 406 sc.users[m.id] = { 407 username: m.username, 408 permissions: m.permissions || [], 409 data: m.data || {}, 410 streams: {}, 411 }; 412 } else { 413 sc.users[m.id].username = m.username; 414 sc.users[m.id].permissions = m.permissions || []; 415 sc.users[m.id].data = m.data || {}; 416 } 417 break; 418 case 'delete': 419 if(!(m.id in sc.users)) 420 console.warn(`Unknown user ${m.id} ${m.username}`); 421 for(let t in sc.transferredFiles) { 422 let f = sc.transferredFiles[t]; 423 if(f.userid === m.id) 424 f.fail('user has gone away'); 425 } 426 delete(sc.users[m.id]); 427 break; 428 default: 429 console.warn(`Unknown user action ${m.kind}`); 430 return; 431 } 432 if(sc.onuser) 433 sc.onuser.call(sc, m.id, m.kind); 434 break; 435 case 'chat': 436 case 'chathistory': 437 if(sc.onchat) 438 sc.onchat.call( 439 sc, m.source, m.dest, m.username, parseTime(m.time), 440 m.privileged, m.type === 'chathistory', m.kind, 441 '' + m.value, 442 ); 443 break; 444 case 'usermessage': 445 if(m.kind === 'filetransfer') 446 sc.fileTransfer(m.source, m.username, m.value); 447 else if(sc.onusermessage) 448 sc.onusermessage.call( 449 sc, m.source, m.dest, m.username, parseTime(m.time), 450 m.privileged, m.kind, m.error, m.value, 451 ); 452 break; 453 case 'ping': 454 sc.send({ 455 type: 'pong', 456 }); 457 break; 458 case 'pong': 459 /* nothing */ 460 break; 461 default: 462 console.warn('Unexpected server message', m.type); 463 return; 464 } 465 }; 466 }); 467 }; 468 469 /** 470 * Protocol version 1 uses integers for dates, later versions use dates in 471 * ISO 8601 format. This function takes a date in either format and 472 * returns a Date object. 473 * 474 * @param {string|number} value 475 * @returns {Date} 476 */ 477 function parseTime(value) { 478 if(!value) 479 return null; 480 try { 481 return new Date(value); 482 } catch(e) { 483 console.warn(`Couldn't parse ${value}:`, e); 484 return null; 485 } 486 } 487 488 /** 489 * join requests to join a group. The onjoined callback will be called 490 * when we've effectively joined. 491 * 492 * @param {string} group - The name of the group to join. 493 * @param {string} username - the username to join as. 494 * @param {string|Object} credentials - password or authServer. 495 * @param {Object<string,any>} [data] - the initial associated data. 496 */ 497 ServerConnection.prototype.join = async function(group, username, credentials, data) { 498 let m = { 499 type: 'join', 500 kind: 'join', 501 group: group, 502 }; 503 if(typeof username !== 'undefined' && username !== null) 504 m.username = username; 505 506 if((typeof credentials) === 'string') { 507 m.password = credentials; 508 } else { 509 switch(credentials.type) { 510 case 'password': 511 m.password = credentials.password; 512 break; 513 case 'token': 514 m.token = credentials.token; 515 break; 516 case 'authServer': 517 let r = await fetch(credentials.authServer, { 518 method: "POST", 519 headers: { 520 "Content-Type": "application/json", 521 }, 522 body: JSON.stringify({ 523 location: credentials.location, 524 username: username, 525 password: credentials.password, 526 }), 527 }); 528 if(!r.ok) 529 throw new Error( 530 `The authorisation server said ${r.status} ${r.statusText}`, 531 ); 532 if(r.status === 204) { 533 // no data, fallback to password auth 534 m.password = credentials.password; 535 break; 536 } 537 let ctype = r.headers.get("Content-Type"); 538 if(!ctype) 539 throw new Error( 540 "The authorisation server didn't return a content type", 541 ); 542 let semi = ctype.indexOf(";"); 543 if(semi >= 0) 544 ctype = ctype.slice(0, semi); 545 ctype = ctype.trim(); 546 switch(ctype.toLowerCase()) { 547 case 'application/jwt': 548 let data = await r.text(); 549 if(!data) 550 throw new Error( 551 "The authorisation server returned empty token", 552 ); 553 m.token = data; 554 break; 555 default: 556 throw new Error(`The authorisation server returned ${ctype}`); 557 break; 558 } 559 break; 560 default: 561 throw new Error(`Unknown credentials type ${credentials.type}`); 562 } 563 } 564 565 if(data) 566 m.data = data; 567 568 this.send(m); 569 }; 570 571 /** 572 * leave leaves a group. The onjoined callback will be called when we've 573 * effectively left. 574 * 575 * @param {string} group - The name of the group to join. 576 */ 577 ServerConnection.prototype.leave = function(group) { 578 this.send({ 579 type: 'join', 580 kind: 'leave', 581 group: group, 582 }); 583 }; 584 585 /** 586 * request sets the list of requested tracks 587 * 588 * @param {Object<string,Array<string>>} what 589 * - A dictionary that maps labels to a sequence of 'audio', 'video' 590 * or 'video-low. An entry with an empty label '' provides the default. 591 */ 592 ServerConnection.prototype.request = function(what) { 593 this.send({ 594 type: 'request', 595 request: what, 596 }); 597 }; 598 599 /** 600 * findByLocalId finds an active connection with the given localId. 601 * It returns null if none was find. 602 * 603 * @param {string} localId 604 * @returns {Stream} 605 */ 606 ServerConnection.prototype.findByLocalId = function(localId) { 607 if(!localId) 608 return null; 609 610 let sc = this; 611 612 for(let id in sc.up) { 613 let s = sc.up[id]; 614 if(s.localId === localId) 615 return s; 616 } 617 return null; 618 } 619 620 /** 621 * getRTCConfiguration returns the RTCConfiguration that should be used 622 * with this peer connection. This usually comes from the server, but may 623 * be overridden by the onpeerconnection callback. 624 * 625 * @returns {RTCConfiguration} 626 */ 627 ServerConnection.prototype.getRTCConfiguration = function() { 628 if(this.onpeerconnection) { 629 let conf = this.onpeerconnection.call(this); 630 if(conf !== null) 631 return conf; 632 } 633 return this.rtcConfiguration; 634 } 635 636 /** 637 * newUpStream requests the creation of a new up stream. 638 * 639 * @param {string} [localId] 640 * - The local id of the stream to create. If a stream already exists with 641 * the same local id, it is replaced with the new stream. 642 * @returns {Stream} 643 */ 644 ServerConnection.prototype.newUpStream = function(localId) { 645 let sc = this; 646 let id = newRandomId(); 647 if(sc.up[id]) 648 throw new Error('Eek!'); 649 650 if(typeof RTCPeerConnection === 'undefined') 651 throw new Error("This browser doesn't support WebRTC"); 652 653 654 let pc = new RTCPeerConnection(sc.getRTCConfiguration()); 655 if(!pc) 656 throw new Error("Couldn't create peer connection"); 657 658 let oldId = null; 659 if(localId) { 660 let old = sc.findByLocalId(localId); 661 oldId = old && old.id; 662 if(old) 663 old.close(true); 664 } 665 666 let c = new Stream(this, id, localId || newLocalId(), pc, true); 667 if(oldId) 668 c.replace = oldId; 669 sc.up[id] = c; 670 671 pc.onnegotiationneeded = async e => { 672 await c.negotiate(); 673 }; 674 675 pc.onicecandidate = e => { 676 if(!e.candidate) 677 return; 678 c.gotLocalIce(e.candidate); 679 }; 680 681 pc.oniceconnectionstatechange = e => { 682 if(c.onstatus) 683 c.onstatus.call(c, pc.iceConnectionState); 684 if(pc.iceConnectionState === 'failed') 685 c.restartIce(); 686 }; 687 688 pc.ontrack = console.error; 689 return c; 690 }; 691 692 /** 693 * chat sends a chat message to the server. The server will normally echo 694 * the message back to the client. 695 * 696 * @param {string} kind 697 * - The kind of message, either '', 'me' or an application-specific type. 698 * @param {string} dest - The id to send the message to, empty for broadcast. 699 * @param {string} value - The text of the message. 700 */ 701 ServerConnection.prototype.chat = function(kind, dest, value) { 702 this.send({ 703 type: 'chat', 704 source: this.id, 705 dest: dest, 706 username: this.username, 707 kind: kind, 708 value: value, 709 }); 710 }; 711 712 /** 713 * userAction sends a request to act on a user. 714 * 715 * @param {string} kind - One of "op", "unop", "kick", "present", "unpresent". 716 * @param {string} dest - The id of the user to act upon. 717 * @param {any} [value] - An action-dependent parameter. 718 */ 719 ServerConnection.prototype.userAction = function(kind, dest, value) { 720 this.send({ 721 type: 'useraction', 722 source: this.id, 723 dest: dest, 724 username: this.username, 725 kind: kind, 726 value: value, 727 }); 728 }; 729 730 /** 731 * userMessage sends an application-specific message to a user. 732 * This is similar to a chat message, but is not saved in the chat history. 733 * 734 * @param {string} kind - The kind of application-specific message. 735 * @param {string} dest - The id to send the message to, empty for broadcast. 736 * @param {unknown} [value] - An optional parameter. 737 * @param {boolean} [noecho] - If set, don't echo back the message to the sender. 738 */ 739 ServerConnection.prototype.userMessage = function(kind, dest, value, noecho) { 740 this.send({ 741 type: 'usermessage', 742 source: this.id, 743 dest: dest, 744 username: this.username, 745 kind: kind, 746 value: value, 747 noecho: noecho, 748 }); 749 }; 750 751 /** 752 * groupAction sends a request to act on the current group. 753 * 754 * @param {string} kind 755 * @param {any} [data] 756 */ 757 ServerConnection.prototype.groupAction = function(kind, data) { 758 this.send({ 759 type: 'groupaction', 760 source: this.id, 761 kind: kind, 762 username: this.username, 763 value: data, 764 }); 765 }; 766 767 /** 768 * gotOffer is called when we receive an offer from the server. Don't call this. 769 * 770 * @param {string} id 771 * @param {string} label 772 * @param {string} source 773 * @param {string} username 774 * @param {string} sdp 775 * @param {string} replace 776 * @function 777 */ 778 ServerConnection.prototype.gotOffer = async function(id, label, source, username, sdp, replace) { 779 let sc = this; 780 781 if(sc.up[id]) { 782 console.error("Duplicate connection id"); 783 sc.send({ 784 type: 'abort', 785 id: id, 786 }); 787 return; 788 } 789 790 let oldLocalId = null; 791 792 if(replace) { 793 let old = sc.down[replace]; 794 if(old) { 795 oldLocalId = old.localId; 796 old.close(true); 797 } else 798 console.error("Replacing unknown stream"); 799 } 800 801 let c = sc.down[id]; 802 if(c && oldLocalId) 803 console.error("Replacing duplicate stream"); 804 805 if(!c) { 806 let pc; 807 try { 808 pc = new RTCPeerConnection(sc.getRTCConfiguration()); 809 } catch(e) { 810 console.error(e); 811 sc.send({ 812 type: 'abort', 813 id: id, 814 }); 815 return; 816 } 817 c = new Stream(this, id, oldLocalId || newLocalId(), pc, false); 818 c.label = label; 819 sc.down[id] = c; 820 821 c.pc.onicecandidate = function(e) { 822 if(!e.candidate) 823 return; 824 c.gotLocalIce(e.candidate); 825 }; 826 827 pc.oniceconnectionstatechange = e => { 828 if(c.onstatus) 829 c.onstatus.call(c, pc.iceConnectionState); 830 if(pc.iceConnectionState === 'failed') { 831 sc.send({ 832 type: 'renegotiate', 833 id: id, 834 }); 835 } 836 }; 837 838 c.pc.ontrack = function(e) { 839 if(e.streams.length < 1) { 840 console.error("Got track with no stream"); 841 return; 842 } 843 c.stream = e.streams[0]; 844 let changed = recomputeUserStreams(sc, source); 845 if(c.ondowntrack) { 846 c.ondowntrack.call( 847 c, e.track, e.transceiver, e.streams[0], 848 ); 849 } 850 if(changed && sc.onuser) 851 sc.onuser.call(sc, source, "change"); 852 }; 853 } 854 855 c.source = source; 856 c.username = username; 857 858 if(sc.ondownstream) 859 sc.ondownstream.call(sc, c); 860 861 try { 862 await c.pc.setRemoteDescription({ 863 type: 'offer', 864 sdp: sdp, 865 }); 866 867 await c.flushRemoteIceCandidates(); 868 869 let answer = await c.pc.createAnswer(); 870 if(!answer) 871 throw new Error("Didn't create answer"); 872 await c.pc.setLocalDescription(answer); 873 this.send({ 874 type: 'answer', 875 id: id, 876 sdp: c.pc.localDescription.sdp, 877 }); 878 } catch(e) { 879 try { 880 if(c.onerror) 881 c.onerror.call(c, e); 882 } finally { 883 c.abort(); 884 } 885 return; 886 } 887 888 c.localDescriptionSent = true; 889 c.flushLocalIceCandidates(); 890 if(c.onnegotiationcompleted) 891 c.onnegotiationcompleted.call(c); 892 }; 893 894 /** 895 * gotAnswer is called when we receive an answer from the server. Don't 896 * call this. 897 * 898 * @param {string} id 899 * @param {string} sdp 900 * @function 901 */ 902 ServerConnection.prototype.gotAnswer = async function(id, sdp) { 903 let c = this.up[id]; 904 if(!c) 905 throw new Error('unknown up stream'); 906 try { 907 await c.pc.setRemoteDescription({ 908 type: 'answer', 909 sdp: sdp, 910 }); 911 } catch(e) { 912 try { 913 if(c.onerror) 914 c.onerror.call(c, e); 915 } finally { 916 c.close(); 917 } 918 return; 919 } 920 await c.flushRemoteIceCandidates(); 921 if(c.onnegotiationcompleted) 922 c.onnegotiationcompleted.call(c); 923 }; 924 925 /** 926 * gotRenegotiate is called when we receive a renegotiation request from 927 * the server. Don't call this. 928 * 929 * @param {string} id 930 * @function 931 */ 932 ServerConnection.prototype.gotRenegotiate = function(id) { 933 let c = this.up[id]; 934 if(!c) 935 throw new Error('unknown up stream'); 936 c.restartIce(); 937 }; 938 939 /** 940 * gotClose is called when we receive a close request from the server. 941 * Don't call this. 942 * 943 * @param {string} id 944 */ 945 ServerConnection.prototype.gotClose = function(id) { 946 let c = this.down[id]; 947 if(!c) { 948 console.warn('unknown down stream', id); 949 return; 950 } 951 c.close(); 952 }; 953 954 /** 955 * gotAbort is called when we receive an abort message from the server. 956 * Don't call this. 957 * 958 * @param {string} id 959 */ 960 ServerConnection.prototype.gotAbort = function(id) { 961 let c = this.up[id]; 962 if(!c) 963 throw new Error('unknown up stream'); 964 c.close(); 965 }; 966 967 /** 968 * gotRemoteIce is called when we receive an ICE candidate from the server. 969 * Don't call this. 970 * 971 * @param {string} id 972 * @param {RTCIceCandidate} candidate 973 * @function 974 */ 975 ServerConnection.prototype.gotRemoteIce = async function(id, candidate) { 976 let c = this.up[id]; 977 if(!c) 978 c = this.down[id]; 979 if(!c) 980 throw new Error('unknown stream'); 981 if(c.pc.remoteDescription) 982 await c.pc.addIceCandidate(candidate).catch(console.warn); 983 else 984 c.remoteIceCandidates.push(candidate); 985 }; 986 987 /** 988 * Stream encapsulates a MediaStream, a set of tracks. 989 * 990 * A stream is said to go "up" if it is from the client to the server, and 991 * "down" otherwise. 992 * 993 * @param {ServerConnection} sc 994 * @param {string} id 995 * @param {string} localId 996 * @param {RTCPeerConnection} pc 997 * 998 * @constructor 999 */ 1000 function Stream(sc, id, localId, pc, up) { 1001 /** 1002 * The associated ServerConnection. 1003 * 1004 * @type {ServerConnection} 1005 * @const 1006 */ 1007 this.sc = sc; 1008 /** 1009 * The id of this stream. 1010 * 1011 * @type {string} 1012 * @const 1013 */ 1014 this.id = id; 1015 /** 1016 * The local id of this stream. 1017 * 1018 * @type {string} 1019 * @const 1020 */ 1021 this.localId = localId; 1022 /** 1023 * Indicates whether the stream is in the client->server direction. 1024 * 1025 * @type {boolean} 1026 * @const 1027 */ 1028 this.up = up; 1029 /** 1030 * For down streams, the id of the client that created the stream. 1031 * 1032 * @type {string} 1033 */ 1034 this.source = null; 1035 /** 1036 * For down streams, the username of the client who created the stream. 1037 * 1038 * @type {string} 1039 */ 1040 this.username = null; 1041 /** 1042 * The associated RTCPeerConnection. This is null before the stream 1043 * is connected, and may change over time. 1044 * 1045 * @type {RTCPeerConnection} 1046 */ 1047 this.pc = pc; 1048 /** 1049 * The associated MediaStream. This is null before the stream is 1050 * connected, and may change over time. 1051 * 1052 * @type {MediaStream} 1053 */ 1054 this.stream = null; 1055 /** 1056 * The label assigned by the originator to this stream. 1057 * 1058 * @type {string} 1059 */ 1060 this.label = null; 1061 /** 1062 * The id of the stream that we are currently replacing. 1063 * 1064 * @type {string} 1065 */ 1066 this.replace = null; 1067 /** 1068 * Indicates whether we have already sent a local description. 1069 * 1070 * @type {boolean} 1071 */ 1072 this.localDescriptionSent = false; 1073 /** 1074 * Buffered local ICE candidates. This will be flushed by 1075 * flushLocalIceCandidates after we send a local description. 1076 * 1077 * @type {RTCIceCandidate[]} 1078 */ 1079 this.localIceCandidates = []; 1080 /** 1081 * Buffered remote ICE candidates. This will be flushed by 1082 * flushRemoteIceCandidates when we get a remote SDP description. 1083 * 1084 * @type {RTCIceCandidate[]} 1085 */ 1086 this.remoteIceCandidates = []; 1087 /** 1088 * The statistics last computed by the stats handler. This is 1089 * a dictionary indexed by track id, with each value a dictionary of 1090 * statistics. 1091 * 1092 * @type {Object<string,unknown>} 1093 */ 1094 this.stats = {}; 1095 /** 1096 * The id of the periodic handler that computes statistics, as 1097 * returned by setInterval. 1098 * 1099 * @type {number} 1100 */ 1101 this.statsHandler = null; 1102 /** 1103 * userdata is a convenient place to attach data to a Stream. 1104 * It is not used by the library. 1105 * 1106 * @type{Object<unknown,unknown>} 1107 */ 1108 this.userdata = {}; 1109 1110 /* Callbacks */ 1111 1112 /** 1113 * onclose is called when the stream is closed. Replace will be true 1114 * if the stream is being replaced by another one with the same id. 1115 * 1116 * @type{(this: Stream, replace: boolean) => void} 1117 */ 1118 this.onclose = null; 1119 /** 1120 * onerror is called whenever a fatal error occurs. The stream will 1121 * then be closed, and onclose called normally. 1122 * 1123 * @type{(this: Stream, error: unknown) => void} 1124 */ 1125 this.onerror = null; 1126 /** 1127 * onnegotiationcompleted is called whenever negotiation or 1128 * renegotiation has completed. 1129 * 1130 * @type{(this: Stream) => void} 1131 */ 1132 this.onnegotiationcompleted = null; 1133 /** 1134 * ondowntrack is called whenever a new track is added to a stream. 1135 * If the stream parameter differs from its previous value, then it 1136 * indicates that the old stream has been discarded. 1137 * 1138 * @type{(this: Stream, track: MediaStreamTrack, transceiver: RTCRtpTransceiver, stream: MediaStream) => void} 1139 */ 1140 this.ondowntrack = null; 1141 /** 1142 * onstatus is called whenever the status of the stream changes. 1143 * 1144 * @type{(this: Stream, status: string) => void} 1145 */ 1146 this.onstatus = null; 1147 /** 1148 * onstats is called when we have new statistics about the connection 1149 * 1150 * @type{(this: Stream, stats: Object<unknown,unknown>) => void} 1151 */ 1152 this.onstats = null; 1153 } 1154 1155 /** 1156 * setStream sets the stream of an upwards connection. 1157 * 1158 * @param {MediaStream} stream 1159 */ 1160 Stream.prototype.setStream = function(stream) { 1161 let c = this; 1162 c.stream = stream; 1163 let changed = recomputeUserStreams(c.sc, c.sc.id); 1164 if(changed && c.sc.onuser) 1165 c.sc.onuser.call(c.sc, c.sc.id, "change"); 1166 } 1167 1168 /** 1169 * close closes a stream. 1170 * 1171 * For streams in the up direction, this may be called at any time. For 1172 * streams in the down direction, this will be called automatically when 1173 * the server signals that it is closing a stream. 1174 * 1175 * @param {boolean} [replace] 1176 * - true if the stream is being replaced by another one with the same id 1177 */ 1178 Stream.prototype.close = function(replace) { 1179 let c = this; 1180 1181 if(!c.sc) { 1182 console.warn('Closing closed stream'); 1183 return; 1184 } 1185 1186 if(c.statsHandler) { 1187 clearInterval(c.statsHandler); 1188 c.statsHandler = null; 1189 } 1190 1191 c.pc.close(); 1192 1193 if(c.up && !replace && c.localDescriptionSent) { 1194 try { 1195 c.sc.send({ 1196 type: 'close', 1197 id: c.id, 1198 }); 1199 } catch(e) { 1200 } 1201 } 1202 1203 let userid; 1204 if(c.up) { 1205 userid = c.sc.id; 1206 if(c.sc.up[c.id] === c) 1207 delete(c.sc.up[c.id]); 1208 else 1209 console.warn('Closing unknown stream'); 1210 } else { 1211 userid = c.source; 1212 if(c.sc.down[c.id] === c) 1213 delete(c.sc.down[c.id]); 1214 else 1215 console.warn('Closing unknown stream'); 1216 } 1217 let changed = recomputeUserStreams(c.sc, userid); 1218 if(changed && c.sc.onuser) 1219 c.sc.onuser.call(c.sc, userid, "change"); 1220 1221 if(c.onclose) 1222 c.onclose.call(c, replace); 1223 1224 c.sc = null; 1225 }; 1226 1227 /** 1228 * recomputeUserStreams recomputes the user.streams array for a given user. 1229 * It returns true if anything changed. 1230 * 1231 * @param {ServerConnection} sc 1232 * @param {string} id 1233 * @returns {boolean} 1234 */ 1235 function recomputeUserStreams(sc, id) { 1236 let user = sc.users[id]; 1237 if(!user) { 1238 console.warn("recomputing streams for unknown user"); 1239 return false; 1240 } 1241 1242 let streams = id === sc.id ? sc.up : sc.down; 1243 let old = user.streams; 1244 user.streams = {}; 1245 for(id in streams) { 1246 let c = streams[id]; 1247 if(!c.stream) 1248 continue; 1249 if(!user.streams[c.label]) 1250 user.streams[c.label] = {}; 1251 c.stream.getTracks().forEach(t => { 1252 user.streams[c.label][t.kind] = true; 1253 }); 1254 } 1255 1256 return JSON.stringify(old) != JSON.stringify(user.streams); 1257 } 1258 1259 /** 1260 * abort requests that the server close a down stream. 1261 */ 1262 Stream.prototype.abort = function() { 1263 let c = this; 1264 if(c.up) 1265 throw new Error("Abort called on an up stream"); 1266 c.sc.send({ 1267 type: 'abort', 1268 id: c.id, 1269 }); 1270 }; 1271 1272 /** 1273 * gotLocalIce is Called when we get a local ICE candidate. Don't call this. 1274 * 1275 * @param {RTCIceCandidate} candidate 1276 * @function 1277 */ 1278 Stream.prototype.gotLocalIce = function(candidate) { 1279 let c = this; 1280 if(c.localDescriptionSent) 1281 c.sc.send({type: 'ice', 1282 id: c.id, 1283 candidate: candidate, 1284 }); 1285 else 1286 c.localIceCandidates.push(candidate); 1287 }; 1288 1289 /** 1290 * flushLocalIceCandidates flushes any buffered local ICE candidates. 1291 * It is called when we send an offer. 1292 * 1293 * @function 1294 */ 1295 Stream.prototype.flushLocalIceCandidates = function () { 1296 let c = this; 1297 let candidates = c.localIceCandidates; 1298 c.localIceCandidates = []; 1299 candidates.forEach(candidate => { 1300 try { 1301 c.sc.send({type: 'ice', 1302 id: c.id, 1303 candidate: candidate, 1304 }); 1305 } catch(e) { 1306 console.warn(e); 1307 } 1308 }); 1309 c.localIceCandidates = []; 1310 }; 1311 1312 /** 1313 * flushRemoteIceCandidates flushes any buffered remote ICE candidates. It is 1314 * called automatically when we get a remote description. 1315 * 1316 * @function 1317 */ 1318 Stream.prototype.flushRemoteIceCandidates = async function () { 1319 let c = this; 1320 let candidates = c.remoteIceCandidates; 1321 c.remoteIceCandidates = []; 1322 /** @type {Array.<Promise<void>>} */ 1323 let promises = []; 1324 candidates.forEach(candidate => { 1325 promises.push(c.pc.addIceCandidate(candidate).catch(console.warn)); 1326 }); 1327 return await Promise.all(promises); 1328 }; 1329 1330 /** 1331 * negotiate negotiates or renegotiates an up stream. It is called 1332 * automatically when required. If the client requires renegotiation, it 1333 * is probably better to call restartIce which will cause negotiate to be 1334 * called asynchronously. 1335 * 1336 * @function 1337 * @param {boolean} [restartIce] - Whether to restart ICE. 1338 */ 1339 Stream.prototype.negotiate = async function (restartIce) { 1340 let c = this; 1341 if(!c.up) 1342 throw new Error('not an up stream'); 1343 1344 let options = {}; 1345 if(restartIce) 1346 options = {iceRestart: true}; 1347 let offer = await c.pc.createOffer(options); 1348 if(!offer) 1349 throw(new Error("Didn't create offer")); 1350 await c.pc.setLocalDescription(offer); 1351 1352 c.sc.send({ 1353 type: 'offer', 1354 source: c.sc.id, 1355 username: c.sc.username, 1356 kind: this.localDescriptionSent ? 'renegotiate' : '', 1357 id: c.id, 1358 replace: this.replace, 1359 label: c.label, 1360 sdp: c.pc.localDescription.sdp, 1361 }); 1362 this.localDescriptionSent = true; 1363 this.replace = null; 1364 c.flushLocalIceCandidates(); 1365 }; 1366 1367 /** 1368 * restartIce causes an ICE restart on a stream. For up streams, it is 1369 * called automatically when ICE signals that the connection has failed, 1370 * but may also be called by the application. For down streams, it 1371 * requests that the server perform an ICE restart. In either case, 1372 * it returns immediately, negotiation will happen asynchronously. 1373 */ 1374 1375 Stream.prototype.restartIce = function () { 1376 let c = this; 1377 if(!c.up) { 1378 c.sc.send({ 1379 type: 'renegotiate', 1380 id: c.id, 1381 }); 1382 return; 1383 } 1384 1385 if('restartIce' in c.pc) { 1386 try { 1387 c.pc.restartIce(); 1388 return; 1389 } catch(e) { 1390 console.warn(e); 1391 } 1392 } 1393 1394 // negotiate is async, but this returns immediately. 1395 c.negotiate(true); 1396 }; 1397 1398 /** 1399 * request sets the list of tracks. If this is not called, or called with 1400 * a null argument, then the default is provided by ServerConnection.request. 1401 * 1402 * @param {Array<string>} what - a sequence of 'audio', 'video' or 'video-low'. 1403 */ 1404 Stream.prototype.request = function(what) { 1405 let c = this; 1406 c.sc.send({ 1407 type: 'requestStream', 1408 id: c.id, 1409 request: what, 1410 }); 1411 }; 1412 1413 /** 1414 * updateStats is called periodically, if requested by setStatsInterval, 1415 * in order to recompute stream statistics and invoke the onstats handler. 1416 * 1417 * @function 1418 */ 1419 Stream.prototype.updateStats = async function() { 1420 let c = this; 1421 let old = c.stats; 1422 /** @type{Object<string,unknown>} */ 1423 let stats = {}; 1424 1425 let transceivers = c.pc.getTransceivers(); 1426 for(let i = 0; i < transceivers.length; i++) { 1427 let t = transceivers[i]; 1428 let stid = t.sender.track && t.sender.track.id; 1429 let rtid = t.receiver.track && t.receiver.track.id; 1430 1431 let report = null; 1432 if(stid) { 1433 try { 1434 report = await t.sender.getStats(); 1435 } catch(e) { 1436 } 1437 } 1438 1439 if(report) { 1440 for(let r of report.values()) { 1441 if(stid && r.type === 'outbound-rtp') { 1442 let id = stid; 1443 // Firefox doesn't implement rid, use ssrc 1444 // to discriminate simulcast tracks. 1445 id = id + '-' + r.ssrc; 1446 if(!('bytesSent' in r)) 1447 continue; 1448 if(!stats[id]) 1449 stats[id] = {}; 1450 stats[id][r.type] = {}; 1451 stats[id][r.type].timestamp = r.timestamp; 1452 stats[id][r.type].bytesSent = r.bytesSent; 1453 if(old[id] && old[id][r.type]) 1454 stats[id][r.type].rate = 1455 ((r.bytesSent - old[id][r.type].bytesSent) * 1000 / 1456 (r.timestamp - old[id][r.type].timestamp)) * 8; 1457 } 1458 } 1459 } 1460 1461 report = null; 1462 if(rtid) { 1463 try { 1464 report = await t.receiver.getStats(); 1465 } catch(e) { 1466 console.error(e); 1467 } 1468 } 1469 1470 if(report) { 1471 for(let r of report.values()) { 1472 if(rtid && r.type === 'inbound-rtp') { 1473 if(!('totalAudioEnergy' in r)) 1474 continue; 1475 if(!stats[rtid]) 1476 stats[rtid] = {}; 1477 stats[rtid][r.type] = {}; 1478 stats[rtid][r.type].timestamp = r.timestamp; 1479 stats[rtid][r.type].totalAudioEnergy = r.totalAudioEnergy; 1480 if(old[rtid] && old[rtid][r.type]) 1481 stats[rtid][r.type].audioEnergy = 1482 (r.totalAudioEnergy - old[rtid][r.type].totalAudioEnergy) * 1000 / 1483 (r.timestamp - old[rtid][r.type].timestamp); 1484 } 1485 } 1486 } 1487 } 1488 1489 c.stats = stats; 1490 1491 if(c.onstats) 1492 c.onstats.call(c, c.stats); 1493 }; 1494 1495 /** 1496 * setStatsInterval sets the interval in milliseconds at which the onstats 1497 * handler will be called. This is only useful for up streams. 1498 * 1499 * @param {number} ms - The interval in milliseconds. 1500 */ 1501 Stream.prototype.setStatsInterval = function(ms) { 1502 let c = this; 1503 if(c.statsHandler) { 1504 clearInterval(c.statsHandler); 1505 c.statsHandler = null; 1506 } 1507 1508 if(ms <= 0) 1509 return; 1510 1511 c.statsHandler = setInterval(() => { 1512 c.updateStats(); 1513 }, ms); 1514 }; 1515 1516 1517 /** 1518 * A file in the process of being transferred. 1519 * These are stored in the ServerConnection.transferredFiles dictionary. 1520 * 1521 * State transitions: 1522 * @example 1523 * '' -> inviting -> connecting -> connected -> done -> closed 1524 * any -> cancelled -> closed 1525 * 1526 * 1527 * @parm {ServerConnection} sc 1528 * @parm {string} userid 1529 * @parm {string} rid 1530 * @parm {boolean} up 1531 * @parm {string} username 1532 * @parm {string} mimetype 1533 * @parm {number} size 1534 * @constructor 1535 */ 1536 function TransferredFile(sc, userid, id, up, username, name, mimetype, size) { 1537 /** 1538 * The server connection this file is associated with. 1539 * 1540 * @type {ServerConnection} 1541 */ 1542 this.sc = sc; 1543 /** The id of the remote peer. 1544 * 1545 * @type {string} 1546 */ 1547 this.userid = userid; 1548 /** 1549 * The id of this file transfer. 1550 * 1551 * @type {string} 1552 */ 1553 this.id = id; 1554 /** 1555 * True if this is an upload. 1556 * 1557 * @type {boolean} 1558 */ 1559 this.up = up; 1560 /** 1561 * The state of this file transfer. See the description of the 1562 * constructor for possible state transitions. 1563 * 1564 * @type {string} 1565 */ 1566 this.state = ''; 1567 /** 1568 * The username of the remote peer. 1569 * 1570 * @type {string} 1571 */ 1572 this.username = username; 1573 /** 1574 * The name of the file being transferred. 1575 * 1576 * @type {string} 1577 */ 1578 this.name = name; 1579 /** 1580 * The MIME type of the file being transferred. 1581 * 1582 * @type {string} 1583 */ 1584 this.mimetype = mimetype; 1585 /** 1586 * The size in bytes of the file being transferred. 1587 * 1588 * @type {number} 1589 */ 1590 this.size = size; 1591 /** 1592 * The file being uploaded. Unused for downloads. 1593 * 1594 * @type {File} 1595 */ 1596 this.file = null; 1597 /** 1598 * The peer connection used for the transfer. 1599 * 1600 * @type {RTCPeerConnection} 1601 */ 1602 this.pc = null; 1603 /** 1604 * The datachannel used for the transfer. 1605 * 1606 * @type {RTCDataChannel} 1607 */ 1608 this.dc = null; 1609 /** 1610 * Buffered remote ICE candidates. 1611 * 1612 * @type {Array<RTCIceCandidateInit>} 1613 */ 1614 this.candidates = []; 1615 /** 1616 * The data received to date, stored as a list of blobs or array buffers, 1617 * depending on what the browser supports. 1618 * 1619 * @type {Array<Blob|ArrayBuffer>} 1620 */ 1621 this.data = []; 1622 /** 1623 * The total size of the data received to date. 1624 * 1625 * @type {number} 1626 */ 1627 this.datalen = 0; 1628 /** 1629 * The main filetransfer callback. 1630 * 1631 * This is called whenever the state of the transfer changes, 1632 * but may also be called multiple times in a single state, for example 1633 * in order to display a progress bar. Call this.cancel in order 1634 * to cancel the transfer. 1635 * 1636 * @type {(this: TransferredFile, type: string, [data]: string) => void} 1637 */ 1638 this.onevent = null; 1639 } 1640 1641 /** 1642 * The full id of this file transfer, used as a key in the transferredFiles 1643 * dictionary. 1644 */ 1645 TransferredFile.prototype.fullid = function() { 1646 return this.userid + (this.up ? '+' : '-') + this.id; 1647 }; 1648 1649 /** 1650 * Retrieve a transferred file from the transferredFiles dictionary. 1651 * 1652 * @param {string} userid 1653 * @param {string} fileid 1654 * @param {boolean} up 1655 * @returns {TransferredFile} 1656 */ 1657 ServerConnection.prototype.getTransferredFile = function(userid, fileid, up) { 1658 return this.transferredFiles[userid + (up ? '+' : '-') + fileid]; 1659 }; 1660 1661 /** 1662 * Close a file transfer and remove it from the transferredFiles dictionary. 1663 * Do not call this, call 'cancel' instead. 1664 */ 1665 TransferredFile.prototype.close = function() { 1666 let f = this; 1667 if(f.state === 'closed') 1668 return; 1669 if(f.state !== 'done' && f.state !== 'cancelled') 1670 console.warn( 1671 `TransferredFile.close called in unexpected state ${f.state}`, 1672 ); 1673 if(f.dc) { 1674 f.dc.onclose = null; 1675 f.dc.onerror = null; 1676 f.dc.onmessage = null; 1677 } 1678 if(f.pc) 1679 f.pc.close(); 1680 f.dc = null; 1681 f.pc = null; 1682 f.data = []; 1683 f.datalen = 0; 1684 delete(f.sc.transferredFiles[f.fullid()]); 1685 f.event('closed'); 1686 } 1687 1688 /** 1689 * Buffer a chunk of data received during a file transfer. 1690 * Do not call this, it is called automatically when data is received. 1691 * 1692 * @param {Blob|ArrayBuffer} data 1693 */ 1694 TransferredFile.prototype.bufferData = function(data) { 1695 let f = this; 1696 if(f.up) 1697 throw new Error('buffering data in the wrong direction'); 1698 if(data instanceof Blob) { 1699 f.datalen += data.size; 1700 } else if(data instanceof ArrayBuffer) { 1701 f.datalen += data.byteLength; 1702 } else { 1703 throw new Error('unexpected type for received data'); 1704 } 1705 f.data.push(data); 1706 } 1707 1708 /** 1709 * Retreive the data buffered during a file transfer. Don't call this. 1710 * 1711 * @returns {Blob} 1712 */ 1713 TransferredFile.prototype.getBufferedData = function() { 1714 let f = this; 1715 if(f.up) 1716 throw new Error('buffering data in wrong direction'); 1717 let blob = new Blob(f.data, {type: f.mimetype}); 1718 if(blob.size != f.datalen) 1719 throw new Error('Inconsistent data size'); 1720 f.data = []; 1721 f.datalen = 0; 1722 return blob; 1723 } 1724 1725 /** 1726 * Set the file's state, and call the onevent callback. 1727 * 1728 * This calls the callback even if the state didn't change, which is 1729 * useful if the client needs to display a progress bar. 1730 * 1731 * @param {string} state 1732 * @param {any} [data] 1733 */ 1734 TransferredFile.prototype.event = function(state, data) { 1735 let f = this; 1736 f.state = state; 1737 if(f.onevent) 1738 f.onevent.call(f, state, data); 1739 } 1740 1741 1742 /** 1743 * Cancel a file transfer. 1744 * 1745 * Depending on the state, this will either forcibly close the connection, 1746 * send a handshake, or do nothing. It will set the state to cancelled. 1747 * 1748 * @param {string|Error} [data] 1749 */ 1750 TransferredFile.prototype.cancel = function(data) { 1751 let f = this; 1752 if(f.state === 'closed') 1753 return; 1754 if(f.state !== '' && f.state !== 'done' && f.state !== 'cancelled') { 1755 let m = { 1756 type: f.up ? 'cancel' : 'reject', 1757 id: f.id, 1758 }; 1759 if(data) 1760 m.message = data.toString(); 1761 f.sc.userMessage('filetransfer', f.userid, m); 1762 } 1763 if(f.state !== 'done' && f.state !== 'cancelled') 1764 f.event('cancelled', data); 1765 f.close(); 1766 } 1767 1768 /** 1769 * Forcibly terminate a file transfer. 1770 * 1771 * This is like cancel, but will not attempt to handshake. 1772 * Use cancel instead of this, unless you know what you are doing. 1773 * 1774 * @param {string|Error} [data] 1775 */ 1776 TransferredFile.prototype.fail = function(data) { 1777 let f = this; 1778 if(f.state === 'done' || f.state === 'cancelled' || f.state === 'closed') 1779 return; 1780 f.event('cancelled', data); 1781 f.close(); 1782 } 1783 1784 /** 1785 * Initiate a file upload. 1786 * 1787 * This will cause the onfiletransfer callback to be called, at which 1788 * point you should set up the onevent callback. 1789 * 1790 * @param {string} id 1791 * @param {File} file 1792 */ 1793 ServerConnection.prototype.sendFile = function(id, file) { 1794 let sc = this; 1795 let fileid = newRandomId(); 1796 let user = sc.users[id]; 1797 if(!user) 1798 throw new Error('offering upload to unknown user'); 1799 let f = new TransferredFile( 1800 sc, id, fileid, true, user.username, file.name, file.type, file.size, 1801 ); 1802 f.file = file; 1803 1804 try { 1805 if(sc.onfiletransfer) 1806 sc.onfiletransfer.call(sc, f); 1807 else 1808 throw new Error('this client does not implement file transfer'); 1809 } catch(e) { 1810 f.cancel(e); 1811 return; 1812 } 1813 1814 sc.transferredFiles[f.fullid()] = f; 1815 sc.userMessage('filetransfer', id, { 1816 type: 'invite', 1817 id: fileid, 1818 name: f.name, 1819 size: f.size, 1820 mimetype: f.mimetype, 1821 }); 1822 f.event('inviting'); 1823 } 1824 1825 /** 1826 * Receive a file. 1827 * 1828 * Call this after the onfiletransfer callback has yielded an incoming 1829 * file (up field set to false). If you wish to reject the file transfer, 1830 * call cancel instead. 1831 */ 1832 TransferredFile.prototype.receive = async function() { 1833 let f = this; 1834 if(f.up) 1835 throw new Error('Receiving in wrong direction'); 1836 if(f.pc) 1837 throw new Error('Download already in progress'); 1838 let pc = new RTCPeerConnection(f.sc.getRTCConfiguration()); 1839 if(!pc) { 1840 let err = new Error("Couldn't create peer connection"); 1841 f.fail(err); 1842 return; 1843 } 1844 f.pc = pc; 1845 f.event('connecting'); 1846 1847 f.candidates = []; 1848 pc.onsignalingstatechange = function(e) { 1849 if(pc.signalingState === 'stable') { 1850 f.candidates.forEach(c => pc.addIceCandidate(c).catch(console.warn)); 1851 f.candidates = []; 1852 } 1853 }; 1854 pc.onicecandidate = function(e) { 1855 f.sc.userMessage('filetransfer', f.userid, { 1856 type: 'downice', 1857 id: f.id, 1858 candidate: e.candidate, 1859 }); 1860 }; 1861 f.dc = pc.createDataChannel('file'); 1862 f.data = []; 1863 f.datalen = 0; 1864 f.dc.onclose = function(e) { 1865 f.cancel('remote peer closed connection'); 1866 }; 1867 f.dc.onmessage = function(e) { 1868 f.receiveData(e.data).catch(e => f.cancel(e)); 1869 }; 1870 f.dc.onerror = function(e) { 1871 /** @ts-ignore */ 1872 let err = e.error; 1873 f.cancel(err) 1874 }; 1875 let offer = await pc.createOffer(); 1876 if(!offer) { 1877 f.cancel(new Error("Couldn't create offer")); 1878 return; 1879 } 1880 await pc.setLocalDescription(offer); 1881 f.sc.userMessage('filetransfer', f.userid, { 1882 type: 'offer', 1883 id: f.id, 1884 sdp: pc.localDescription.sdp, 1885 }); 1886 } 1887 1888 /** 1889 * Negotiate a file transfer on the sender side. 1890 * Don't call this, it is called automatically we receive an offer. 1891 * 1892 * @param {string} sdp 1893 */ 1894 TransferredFile.prototype.answer = async function(sdp) { 1895 let f = this; 1896 if(!f.up) 1897 throw new Error('Sending file in wrong direction'); 1898 if(f.pc) 1899 throw new Error('Transfer already in progress'); 1900 let pc = new RTCPeerConnection(f.sc.getRTCConfiguration()); 1901 if(!pc) { 1902 let err = new Error("Couldn't create peer connection"); 1903 f.fail(err); 1904 return; 1905 } 1906 f.pc = pc; 1907 f.event('connecting'); 1908 1909 f.candidates = []; 1910 pc.onicecandidate = function(e) { 1911 f.sc.userMessage('filetransfer', f.userid, { 1912 type: 'upice', 1913 id: f.id, 1914 candidate: e.candidate, 1915 }); 1916 }; 1917 pc.onsignalingstatechange = function(e) { 1918 if(pc.signalingState === 'stable') { 1919 f.candidates.forEach(c => pc.addIceCandidate(c).catch(console.warn)); 1920 f.candidates = []; 1921 } 1922 }; 1923 pc.ondatachannel = function(e) { 1924 if(f.dc) { 1925 f.cancel(new Error('Duplicate datachannel')); 1926 return; 1927 } 1928 f.dc = /** @type{RTCDataChannel} */(e.channel); 1929 f.dc.onclose = function(e) { 1930 f.cancel('remote peer closed connection'); 1931 }; 1932 f.dc.onerror = function(e) { 1933 /** @ts-ignore */ 1934 let err = e.error; 1935 f.cancel(err); 1936 } 1937 f.dc.onmessage = function(e) { 1938 if(e.data === 'done' && f.datalen === f.size) { 1939 f.event('done'); 1940 f.dc.onclose = null; 1941 f.dc.onerror = null; 1942 f.close(); 1943 } else { 1944 f.cancel(new Error('unexpected data from receiver')); 1945 } 1946 } 1947 f.send().catch(e => f.cancel(e)); 1948 }; 1949 1950 await pc.setRemoteDescription({ 1951 type: 'offer', 1952 sdp: sdp, 1953 }); 1954 1955 let answer = await pc.createAnswer(); 1956 if(!answer) 1957 throw new Error("Couldn't create answer"); 1958 await pc.setLocalDescription(answer); 1959 f.sc.userMessage('filetransfer', f.userid, { 1960 type: 'answer', 1961 id: f.id, 1962 sdp: pc.localDescription.sdp, 1963 }); 1964 1965 f.event('connected'); 1966 } 1967 1968 /** 1969 * Transfer file data. Don't call this, it is called automatically 1970 * after negotiation completes. 1971 */ 1972 TransferredFile.prototype.send = async function() { 1973 let f = this; 1974 if(!f.up) 1975 throw new Error('sending in wrong direction'); 1976 let r = f.file.stream().getReader(); 1977 1978 f.dc.bufferedAmountLowThreshold = 65536; 1979 1980 /** @param {Uint8Array} a */ 1981 async function write(a) { 1982 while(f.dc.bufferedAmount > f.dc.bufferedAmountLowThreshold) { 1983 await new Promise((resolve, reject) => { 1984 if(!f.dc) { 1985 reject(new Error('File is closed.')); 1986 return; 1987 } 1988 f.dc.onbufferedamountlow = function(e) { 1989 if(!f.dc) { 1990 reject(new Error('File is closed.')); 1991 return; 1992 } 1993 f.dc.onbufferedamountlow = null; 1994 resolve(); 1995 } 1996 }); 1997 } 1998 f.dc.send(a); 1999 f.datalen += a.length; 2000 // we're already in the connected state, but invoke callbacks to 2001 // that the application can display progress 2002 f.event('connected'); 2003 } 2004 2005 while(true) { 2006 let v = await r.read(); 2007 if(v.done) 2008 break; 2009 let data = v.value; 2010 if(!(data instanceof Uint8Array)) 2011 throw new Error('Unexpected type for chunk'); 2012 /* Base SCTP only supports up to 16kB data chunks. There are 2013 extensions to handle larger chunks, but they don't interoperate 2014 between browsers, so we chop the file into small pieces. */ 2015 if(data.length <= 16384) { 2016 await write(data); 2017 } else { 2018 for(let i = 0; i < v.value.length; i += 16384) { 2019 let d = data.subarray(i, Math.min(i + 16384, data.length)); 2020 await write(d); 2021 } 2022 } 2023 } 2024 } 2025 2026 /** 2027 * Called after we receive an answer. Don't call this. 2028 * 2029 * @param {string} sdp 2030 */ 2031 TransferredFile.prototype.receiveFile = async function(sdp) { 2032 let f = this; 2033 if(f.up) 2034 throw new Error('Receiving in wrong direction'); 2035 await f.pc.setRemoteDescription({ 2036 type: 'answer', 2037 sdp: sdp, 2038 }); 2039 f.event('connected'); 2040 } 2041 2042 /** 2043 * Called whenever we receive a chunk of data. Don't call this. 2044 * 2045 * @param {Blob|ArrayBuffer} data 2046 */ 2047 TransferredFile.prototype.receiveData = async function(data) { 2048 let f = this; 2049 if(f.up) 2050 throw new Error('Receiving in wrong direction'); 2051 f.bufferData(data); 2052 2053 if(f.datalen < f.size) { 2054 f.event('connected'); 2055 return; 2056 } 2057 2058 f.dc.onmessage = null; 2059 2060 if(f.datalen != f.size) { 2061 f.cancel('extra data at end of file'); 2062 return; 2063 } 2064 2065 let blob = f.getBufferedData(); 2066 if(blob.size != f.size) { 2067 f.cancel("inconsistent data size (this shouldn't happen)"); 2068 return; 2069 } 2070 f.event('done', blob); 2071 2072 // we've received the whole file. Send the final handshake, but don't 2073 // complain if the peer has closed the channel in the meantime. 2074 await new Promise((resolve, reject) => { 2075 let timer = setTimeout(function(e) { resolve(); }, 2000); 2076 f.dc.onclose = function(e) { 2077 clearTimeout(timer); 2078 resolve(); 2079 }; 2080 f.dc.onerror = function(e) { 2081 clearTimeout(timer); 2082 resolve(); 2083 }; 2084 f.dc.send('done'); 2085 }); 2086 2087 f.close(); 2088 } 2089 2090 /** 2091 * fileTransfer handles a usermessage of kind 'filetransfer'. Don't call 2092 * this, it is called automatically as needed. 2093 * 2094 * @param {string} id 2095 * @param {string} username 2096 * @param {object} message 2097 */ 2098 ServerConnection.prototype.fileTransfer = function(id, username, message) { 2099 let sc = this; 2100 switch(message.type) { 2101 case 'invite': { 2102 let f = new TransferredFile( 2103 sc, id, message.id, false, username, 2104 message.name, message.mimetype, message.size, 2105 ); 2106 f.state = 'inviting'; 2107 2108 try { 2109 if(sc.onfiletransfer) 2110 sc.onfiletransfer.call(sc, f); 2111 else { 2112 f.cancel('this client does not implement file transfer'); 2113 return; 2114 } 2115 } catch(e) { 2116 f.cancel(e); 2117 return; 2118 } 2119 2120 if(f.fullid() in sc.transferredFiles) { 2121 console.error('Duplicate id for file transfer'); 2122 f.cancel("duplicate id (this shouldn't happen)"); 2123 return; 2124 } 2125 sc.transferredFiles[f.fullid()] = f; 2126 break; 2127 } 2128 case 'offer': { 2129 let f = sc.getTransferredFile(id, message.id, true); 2130 if(!f) { 2131 console.error('Unexpected offer for file transfer'); 2132 return; 2133 } 2134 f.answer(message.sdp).catch(e => f.cancel(e)); 2135 break; 2136 } 2137 case 'answer': { 2138 let f = sc.getTransferredFile(id, message.id, false); 2139 if(!f) { 2140 console.error('Unexpected answer for file transfer'); 2141 return; 2142 } 2143 f.receiveFile(message.sdp).catch(e => f.cancel(e)); 2144 break; 2145 } 2146 case 'downice': 2147 case 'upice': { 2148 let f = sc.getTransferredFile( 2149 id, message.id, message.type === 'downice', 2150 ); 2151 if(!f || !f.pc) { 2152 console.warn(`Unexpected ${message.type} for file transfer`); 2153 return; 2154 } 2155 if(f.pc.signalingState === 'stable') 2156 f.pc.addIceCandidate(message.candidate).catch(console.warn); 2157 else 2158 f.candidates.push(message.candidate); 2159 break; 2160 } 2161 case 'cancel': 2162 case 'reject': { 2163 let f = sc.getTransferredFile(id, message.id, message.type === 'reject'); 2164 if(!f) { 2165 console.error(`Unexpected ${message.type} for file transfer`); 2166 return; 2167 } 2168 f.event('cancelled', message.value || null); 2169 f.close(); 2170 break; 2171 } 2172 default: 2173 console.error(`Unknown filetransfer message ${message.type}`); 2174 break; 2175 } 2176 } 2177