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