diff options
Diffstat (limited to 'src/wrtcp.js')
-rw-r--r-- | src/wrtcp.js | 855 |
1 files changed, 855 insertions, 0 deletions
diff --git a/src/wrtcp.js b/src/wrtcp.js new file mode 100644 index 00000000..c23c95d7 --- /dev/null +++ b/src/wrtcp.js @@ -0,0 +1,855 @@ + +(function() { + + // Some functions to help support binary messages for Chrome until there is native support + + function byteLength(string, optEncoding) { + // FIXME: support other encodings + return window.unescape(encodeURIComponent(string)).length; + }; + + /** + * [source]http://closure-library.googlecode.com/svn/docs/closure_goog_crypt_crypt.js.source.html + * Converts a JS string to a UTF-8 "byte" array. + * @param {string} str 16-bit unicode string. + * @return {Array.<number>} UTF-8 byte array. + */ + function stringToUtf8ByteArray(string, offset, length, buffer) { + // TODO(user): Use native implementations if/when available + string = string.replace(/\r\n/g, '\n'); + var p = 0; + for (var i = offset; i < string.length && p < length; i++) { + var c = string.charCodeAt(i); + if (c < 128) { + buffer[p++] = c; + } else if (c < 2048) { + buffer[p++] = (c >> 6) | 192; + buffer[p++] = (c & 63) | 128; + } else { + buffer[p++] = (c >> 12) | 224; + buffer[p++] = ((c >> 6) & 63) | 128; + buffer[p++] = (c & 63) | 128; + } + } + return buffer; + } + + /** + * [source]http://closure-library.googlecode.com/svn/docs/closure_goog_crypt_crypt.js.source.html + * Converts a UTF-8 byte array to JavaScript's 16-bit Unicode. + * @param {Array.<number>} bytes UTF-8 byte array. + * @return {string} 16-bit Unicode string. + */ + function utf8ByteArrayToString(bytes) { + // TODO(user): Use native implementations if/when available + var out = [], pos = 0, c = 0; + var c1, c2, c3; + while (pos < bytes.length) { + c1 = bytes[pos++]; + if (c1 < 128) { + out[c++] = String.fromCharCode(c1); + } else if (c1 > 191 && c1 < 224) { + c2 = bytes[pos++]; + out[c++] = String.fromCharCode((c1 & 31) << 6 | c2 & 63); + } else { + c2 = bytes[pos++]; + c3 = bytes[pos++]; + out[c++] = String.fromCharCode( + (c1 & 15) << 12 | (c2 & 63) << 6 | c3 & 63); + } + } + return out.join(''); + } + + /* Notes + * + * - Continue using prefixed names for now. + * + */ + + var webrtcSupported = true; + + var RTCPeerConnection; + if(window.mozRTCPeerConnection) + RTCPeerConnection = window.mozRTCPeerConnection; + else if(window.webkitRTCPeerConnection) + RTCPeerConnection = window.webkitRTCPeerConnection; + else if(window.RTCPeerConnection) + RTCPeerConnection = window.RTCPeerConnection + else + webrtcSupported = false; + + var RTCSessionDescription; + if(window.mozRTCSessionDescription) + RTCSessionDescription = window.mozRTCSessionDescription; + else if(window.webkitRTCSessionDescription) + RTCSessionDescription = window.webkitRTCSessionDescription; + else if(window.RTCSessionDescription) + RTCSessionDescription = window.RTCSessionDescription + else + webrtcSupported = false; + + var RTCIceCandidate; + if(window.mozRTCIceCandidate) + RTCIceCandidate = window.mozRTCIceCandidate; + else if(window.webkitRTCIceCandidate) + RTCIceCandidate = window.webkitRTCIceCandidate; + else if(window.RTCIceCandidate) + RTCIceCandidate = window.RTCIceCandidate; + else + webrtcSupported = false; + + var getUserMedia; + if(!navigator.getUserMedia) { + if(navigator.mozGetUserMedia) + getUserMedia = navigator.mozGetUserMedia.bind(navigator); + else if(navigator.webkitGetUserMedia) + getUserMedia = navigator.webkitGetUserMedia.bind(navigator); + else + webrtcSupported = false; + } else { + getUserMedia = navigator.getUserMedia.bind(navigator); + } + + // FIXME: browser detection is gross, but I don't see another way to do this + var RTCConnectProtocol; + if(window.mozRTCPeerConnection) { + RTCConnectProtocol = mozRTCConnectProtocol; + } else if(window.webkitRTCPeerConnection) { + RTCConnectProtocol = webkitRTCConnectProtocol; + } else { + webrtcSupported = false; + } + + function callback(object, method, args) { + if(!Array.isArray(args)) + args = [args]; + if(method in object && 'function' === typeof object[method]) { + object[method].apply(object, args); + } + }; + + function fail(object, method, error) { + if (!(error instanceof Error)) + error = new Error(error); + callback(object, method, [error]); + }; + + function defer(queue, object, method, args) { + if(queue) { + queue.push([object, method, args]); + return true; + } else { + return false; + } + }; + + function processDeferredQueue(queue) { + while(queue.length) { + var deferred = queue.shift(); + callback(deferred[0], deferred[1], deferred[2]); + } + }; + + var ONE_SECOND = 1000; // milliseconds + var DEFAULT_CONNECTION_TIMEOUT = 10 * ONE_SECOND; + var DEFAULT_PING_TIMEOUT = 1 * ONE_SECOND; + var RELIABLE_CHANNEL_OPTIONS = { + reliable: false + }; + var UNRELIABLE_CHANNEL_OPTIONS = { + outOfOrderAllowed: true, + maxRetransmitNum: 0, + reliable: false + }; + + function PendingConnectionAbortError(message) { + this.name = "PendingConnectionAbortError"; + this.message = (message || ""); + }; + PendingConnectionAbortError.prototype = Error.prototype; + + function ConnectionFailedError(message) { + this.name = "ConnectionFailedError"; + this.message = (message || ""); + }; + ConnectionFailedError.prototype = Error.prototype; + + var E = { + PendingConnectionAbortError: PendingConnectionAbortError, + ConnectionFailedError: ConnectionFailedError + }; + + function WebSocketBroker(brokerUrl) { + this.brokerUrl = brokerUrl; + this.state = WebSocketBroker.OFFLINE; + + this.onstatechange = null; + this.onreceive = null; + this.onerror = null; + + this.socket = null; + this.route = null; + }; + + // States + WebSocketBroker.OFFLINE = 0x00; + WebSocketBroker.CONNECTING = 0x01; + WebSocketBroker.CONNECTED = 0x02; + // Flags + WebSocketBroker.ROUTED = 0x10; + WebSocketBroker.LISTENING = 0x20; + + WebSocketBroker.prototype.setState = function setState(state, clearFlags) { + var clear = clearFlags ? 0x00 : 0xF0; + this.state &= clear >>> 0; + this.state |= state >>> 0; + callback(this, 'onstatechange', [this.state, (state | (clear & 0x0)) >>> 0]); + }; + WebSocketBroker.prototype.setFlag = function setFlag(flag) { + this.state = (this.state | flag) >>> 0; + callback(this, 'onstatechange', [this.state, flag]) + }; + WebSocketBroker.prototype.clearFlag = function clearFlag(flag) { + flag = (~flag) >>> 0; + this.state = (this.state & flag) >>> 0; + callback(this, 'onstatechange', [this.state, flag]) + }; + WebSocketBroker.prototype.checkState = function checkState(mask) { + return !!(this.state & mask); + }; + WebSocketBroker.prototype.connect = function connect() { + var that = this; + var socket = io.connect(this.brokerUrl + '/peer', { + 'sync disconnect on unload': true // partially fixes 'interrupted while page loading' warning + }); + + socket.on('connecting', function onconnecting() { + that.setState(WebSocketBroker.CONNECTING, true); + }); + + socket.on('connect', function onconnect() { + that.setState(WebSocketBroker.CONNECTED, true); + }); + + socket.on('connect_failed', function onconnect_failed() { + that.setState(WebSocketBroker.OFFLINE, true); + }); + + socket.on('route', function onroute(route) { + that.route = route; + that.setFlag(WebSocketBroker.ROUTED); + }); + + socket.on('disconnect', function ondisconnect() { + that.setState(WebSocketBroker.OFFLINE, true); + }); + + socket.on('error', function onerror(error) { + fail(that, 'onerror', error); + }); + + socket.on('receive', function onreceive(message) { + var from = message['from']; + var data = message['data']; + callback(that, 'onreceive', [from, data]); + }); + + this.socket = socket; + }; + WebSocketBroker.prototype.disconnect = function disconnect() { + if(this.checkState(WebSocketBroker.CONNECTED)) { + this.socket.disconnect(); + this.setState(WebSocketBroker.OFFLINE, true); + return true; + } else { + return false; + } + }; + WebSocketBroker.prototype.listen = function listen(options) { + var that = this; + if(this.checkState(WebSocketBroker.CONNECTED)) { + this.socket.emit('listen', options, function onresponse(response) { + if(response && response['error']) { + var error = new Error(response['error']); + fail(that, 'onerror', error); + } else { + that.setFlag(WebSocketBroker.LISTENING); + } + }); + } + }; + WebSocketBroker.prototype.ignore = function ignore() { + var that = this; + if(this.checkState(WebSocketBroker.CONNECTED)) { + this.socket.emit('ignore', null, function onresponse(response) { + if(response && response['error']) { + var error = new Error(response['error']); + fail(that, 'onerror', error) + } else { + that.clearFlag(WebSocketBroker.LISTENING); + } + }); + } + }; + WebSocketBroker.prototype.send = function send(to, message) { + var that = this; + if(this.checkState(WebSocketBroker.CONNECTED)) { + this.socket.emit('send', {'to': to, 'data': message}, function onresponse(response) { + if(response && response['error']) { + var error = new Error(response['error']); + fail(that, 'onerror', error) + } + }); + }; + }; + + var dataChannels = { + 'reliable': 'RELIABLE', + 'unreliable': 'UNRELIABLE', + '@control': 'RELIABLE' + }; + var nextDataConnectionPort = 1; + function CommonRTCConnectProtocol() { + // FIXME: these timeouts should be configurable + this.connectionTimeout = 10 * ONE_SECOND; + this.pingTimeout = 1 * ONE_SECOND; + }; + CommonRTCConnectProtocol.prototype.process = function process(message) { + var that = this; + + var type = message['type']; + switch(type) { + case 'ice': + var candidate = JSON.parse(message['candidate']); + if(candidate) + this.handleIce(candidate); + break; + + case 'offer': + that.ports.remote = message['port']; + var offer = { + 'type': 'offer', + 'sdp': message['description'] + }; + this.handleOffer(offer); + break; + + case 'answer': + that.ports.remote = message['port']; + var answer = { + 'type': 'answer', + 'sdp': message['description'] + }; + this.handleAnswer(answer); + break; + + case 'abort': + this.handleAbort(); + break; + + default: + fail(this, 'onerror', 'unknown message'); + } + }; + CommonRTCConnectProtocol.prototype.handleAbort = function handleAbort() { + fail(this, 'onerror', new Error(E.RTCConnectProtocolAbort)); + }; + CommonRTCConnectProtocol.prototype.initialize = function initialize(cb) { + var that = this; + + if(this.peerConnection) + return cb(); + + // FIXME: peer connection servers should be configurable + this.peerConnection = new RTCPeerConnection(this.connectionServers, this.connectionOptions); + this.peerConnection.onicecandidate = function(event) { + var message = { + 'type': 'ice', + 'candidate': JSON.stringify(event.candidate) + }; + callback(that, 'onmessage', message); + }; + this.peerConnection.onaddstream = function(event) { + that.streams['remote'] = event.stream; + }; + this.peerConnection.onstatechange = function(event) { + console.log(event.target.readyState); + }; + + function createStream(useFake) { + useFake = (!useVideo && !useAudio) ? true : useFake; + var useVideo = !!that.options['video']; + var useAudio = !!that.options['audio']; + var mediaOptions = { + video: useVideo, + audio: (!useVideo && !useAudio) ? true : useAudio, + fake: useFake + }; + getUserMedia(mediaOptions, + function(stream) { + that.peerConnection.addStream(stream); + that.streams['local'] = stream; + cb(); + }, + function(error) { + console.error('!', error); + if(!useFake) + createStream(true); + else + fail(that, 'onerror', error); + } + ); + } + + createStream(); + }; + CommonRTCConnectProtocol.prototype.handleIce = function handleIce(candidate) { + var that = this; + + function setIce() { + if(!that.peerConnection.remoteDescription) { + return + } + that.peerConnection.addIceCandidate(new RTCIceCandidate(candidate), + function(error) { + fail(that, 'onerror', error); + } + ); + }; + + this.initialize(setIce); + }; + CommonRTCConnectProtocol.prototype.initiate = function initiate() { + var that = this; + this.initiator = true; + + function createDataChannels() { + var labels = Object.keys(dataChannels); + labels.forEach(function(label) { + var channelOptions = that.channelOptions[dataChannels[label]]; + var channel = that._pending[label] = that.peerConnection.createDataChannel(label, channelOptions); + channel.binaryType = that.options['binaryType']; + channel.onopen = function() { + that.channels[label] = channel; + delete that._pending[label]; + if(Object.keys(that.channels).length === labels.length) { + that.complete = true; + callback(that, 'oncomplete', []); + } + }; + channel.onerror = function(error) { + console.error(error); + fail(that, 'onerror', error); + }; + }); + createOffer(); + }; + + function createOffer() { + that.peerConnection.createOffer(setLocal, + function(error) { + fail(that, 'onerror', error); + } + ); + }; + + function setLocal(description) { + that.peerConnection.setLocalDescription(new RTCSessionDescription(description), complete, + function(error) { + fail(that, 'onerror', error); + } + ); + + function complete() { + var message = { + 'type': 'offer', + 'description': description['sdp'], + 'port': that.ports.local + }; + callback(that, 'onmessage', message); + }; + }; + + this.initialize(createDataChannels); + }; + CommonRTCConnectProtocol.prototype.handleOffer = function handleOffer(offer) { + var that = this; + + function handleDataChannels() { + var labels = Object.keys(dataChannels); + that.peerConnection.ondatachannel = function(event) { + var channel = event.channel; + var label = channel.label; + that._pending[label] = channel; + channel.binaryType = that.options['binaryType']; + channel.onopen = function() { + that.channels[label] = channel; + delete that._pending[label]; + if(Object.keys(that.channels).length === labels.length) { + that.complete = true; + callback(that, 'oncomplete', []); + } + }; + channel.onerror = function(error) { + console.error(error); + fail(that, 'onerror', error); + }; + }; + setRemote(); + }; + + function setRemote() { + that.peerConnection.setRemoteDescription(new RTCSessionDescription(offer), createAnswer, + function(error) { + fail(that, 'onerror', error); + } + ); + }; + + function createAnswer() { + that.peerConnection.createAnswer(setLocal, + function(error) { + fail(that, 'onerror', error); + } + ); + }; + + function setLocal(description) { + that.peerConnection.setLocalDescription(new RTCSessionDescription(description), complete, + function(error) { + fail(that, 'onerror', error); + } + ); + + function complete() { + var message = { + 'type': 'answer', + 'description': description['sdp'], + 'port': that.ports.local + }; + callback(that, 'onmessage', message); + }; + }; + + this.initialize(handleDataChannels); + }; + CommonRTCConnectProtocol.prototype.handleAnswer = function handleAnswer(answer) { + var that = this; + + function setRemote() { + that.peerConnection.setRemoteDescription(new RTCSessionDescription(answer), complete, + function(error) { + fail(that, 'onerror', error); + } + ); + }; + + function complete() { + }; + + this.initialize(setRemote); + }; + + function mozRTCConnectProtocol(options) { + this.options = options; + this.onmessage = null; + this.oncomplete = null; + this.onerror = null; + + this.complete = false; + this.ports = { + local: nextDataConnectionPort ++, + remote: null + }; + this.streams = { + local: null, + remote: null + }; + this.initiator = false; + + this.peerConnection = null; + this.channels = {}; + this._pending = {}; + this.connectionServers = null; + this.connectionOptions = null; + this.channelOptions = { + RELIABLE: { + // defaults + }, + UNRELIABLE: { + outOfOrderAllowed: true, + maxRetransmitNum: 0 + } + }; + }; + mozRTCConnectProtocol.prototype = new CommonRTCConnectProtocol(); + mozRTCConnectProtocol.prototype.constructor = mozRTCConnectProtocol; + + function webkitRTCConnectProtocol(options) { + this.options = options; + this.onmessage = null; + this.oncomplete = null; + this.onerror = null; + + this.complete = false; + this.ports = { + local: nextDataConnectionPort ++, + remote: null + }; + this.streams = { + local: null, + remote: null + }; + this.initiator = false; + + this.peerConnection = null; + this.channels = {}; + this._pending = {}; + this.connectionServers = {iceServers:[{url:'stun:23.21.150.121'}]}; + this.connectionOptions = { + 'optional': [{ 'RtpDataChannels': true }] + }; + this.channelOptions = { + RELIABLE: { + // FIXME: reliable channels do not work in chrome yet + reliable: false + }, + UNRELIABLE: { + reliable: false + } + }; + }; + webkitRTCConnectProtocol.prototype = new CommonRTCConnectProtocol(); + webkitRTCConnectProtocol.prototype.constructor = webkitRTCConnectProtocol; + + // FIXME: this could use a cleanup + var nextConnectionId = 1; + function Connection(options, peerConnection, streams, channels) { + var that = this; + this.id = nextConnectionId ++; + this.streams = streams; + this.connected = false; + this.messageFlag = false; + + this.onmessage = null; + this.ondisconnect = null; + this.onerror = null; + + this.peerConnection = peerConnection; + + // DataChannels + this.channels = channels; + + this.connectionTimer = null; + this.pingTimer = null; + + function handleConnectionTimerExpired() { + if(!that.connected) + return + this.connectionTimer = null; + if(false === that.messageFlag) { + that.channels['@control'].send('ping'); + this.pingTimer = window.setTimeout(handlePingTimerExpired, options['pingTimeout']); + } else { + that.messageFlag = false; + this.connectionTimer = window.setTimeout(handleConnectionTimerExpired, options['connectionTimeout']); + } + }; + function handlePingTimerExpired() { + if(!that.connected) + return + this.pingTimer = null; + if(false === that.messageFlag) { + that.connected = false; + that.close(); + } else { + that.messageFlag = false; + this.connectionTimer = window.setTimeout(handleConnectionTimerExpired, options['connectionTimeout']); + } + }; + + Object.keys(this.channels).forEach(function(label) { + var channel = that.channels[label]; + if(label.match('^@')) // check for internal channels + return; + + channel.onmessage = function onmessage(message) { + that.messageFlag = true; + callback(that, 'onmessage', [label, message]); + }; + }); + this.channels['@control'].onmessage = function onmessage(message) { + that.messageFlag = true; + if(that.connected) { + var data = message.data; + if('ping' === data) { + that.channels['@control'].send('pong'); + } else if('pong' === data) { + // ok + } else if('quit' === data) { + that.close(); + } + } + }; + + this.connected = true; + this.connectionTimer = window.setTimeout(handleConnectionTimerExpired, options['connectionTimeout']); + }; + Connection.prototype.close = function close() { + console.log('close connection'); + if(this.connected) { + this.channels['@control'].send('quit'); + } + this.connected = false; + this.peerConnection.close(); + if(this.connectionTimer) { + window.clearInterval(this.connectionTimer); + this.connectionTimer = null; + } + if(this.pingTimer) { + window.clearInterval(this.pingTimer); + this.pingTimer = null; + } + this.peerConnection = null; + callback(this, 'ondisconnect', []); + }; + Connection.prototype.send = function send(label, message) { + this.channels[label].send(message); + }; + + function PendingConnection(route, incoming) { + this.route = route; + this.incoming = incoming; + this.proceed = true; + }; + PendingConnection.prototype.accept = function accept() { + this.proceed = true; + }; + PendingConnection.prototype.reject = function reject() { + this.proceed = false; + }; + + function Peer(brokerUrl, options) { + if(!webrtcSupported) + throw new Error("WebRTC not supported"); + + var that = this; + this.brokerUrl = brokerUrl; + this.options = options = options || {}; + options['binaryType'] = options['binaryType'] || 'arraybuffer'; + options['connectionTimeout'] = options['connectionTimeout'] || 10 * ONE_SECOND; + options['pingTimeout'] = options['pingTimeout'] || 1 * ONE_SECOND; + + this.onconnection = null; + this.onpending = null; + this.onroute = null; + this.onerror = null; + + this.broker = new WebSocketBroker(brokerUrl); + this.pending = {}; + + this.queues = { + connected: [], + listening: [] + }; + + this.broker.onstatechange = function onstatechange(state, mask) { + if(that.queues.connected.length && that.broker.checkState(WebSocketBroker.ROUTED)) { + processDeferredQueue(that.queues.connected); + if(that.queues.listening.length && that.broker.checkState(WebSocketBroker.LISTENING)) { + processDeferredQueue(that.queues.listening); + } + } + if(mask & WebSocketBroker.ROUTED) { + callback(that, 'onroute', that.broker.route); + } + }; + + this.broker.onreceive = function onreceive(from, message) { + var handshake; + if(!that.pending.hasOwnProperty(from)) { + if(!that.broker.checkState(WebSocketBroker.LISTENING)) { + return; + } + + var pendingConnection = new PendingConnection(from, /*incoming*/ true); + callback(that, 'onpending', [pendingConnection]); + if(!pendingConnection['proceed']) + return; + + var handshake = that.pending[from] = new RTCConnectProtocol(that.options); + handshake.oncomplete = function() { + var connection = new Connection(that.options, handshake.peerConnection, handshake.streams, handshake.channels); + connection['route'] = from; + delete that.pending[from]; + callback(that, 'onconnection', [connection]); + }; + handshake.onmessage = function(message) { + that.broker.send(from, message); + }; + handshake.onerror = function(error) { + delete that.pending[from]; + callback(that, 'onerror', [error]); + }; + } else { + handshake = that.pending[from]; + } + handshake.process(message); + }; + + this.broker.connect(); + }; + Peer.prototype.listen = function listen(options) { + if(!this.broker.checkState(WebSocketBroker.ROUTED)) + return defer(this.queues.connected, this, 'listen', [options]); + + options = options || {}; + options['url'] = options['url'] || window.location.toString(); + options['listed'] = (undefined !== options['listed']) ? options['listed'] : true; + options['metadata'] = options['metadata'] || {}; + + this.broker.listen(options); + }; + Peer.prototype.ignore = function ignore() { + throw new Error('not implemented'); + }; + Peer.prototype.connect = function connect(route) { + if(!this.broker.checkState(WebSocketBroker.ROUTED)) + return defer(this.queues.connected, this, 'connect', [route]); + + var that = this; + + if(this.pending.hasOwnProperty(route)) + throw new Error('already connecting to this host'); // FIXME: we can handle this better + + var pendingConnection = new PendingConnection(route, /*incoming*/ false); + callback(that, 'onpending', [pendingConnection]); + if(!pendingConnection['proceed']) + return; + + var handshake = this.pending[route] = new RTCConnectProtocol(this.options); + handshake.oncomplete = function() { + var connection = new Connection(this.options, handshake.peerConnection, handshake.streams, handshake.channels); + connection['route'] = route; + delete that.pending[route]; + callback(that, 'onconnection', [connection]); + }; + handshake.onmessage = function(message) { + that.broker.send(route, message); + }; + handshake.onerror = function(error) { + delete that.pending[route]; + fail(that, 'onerror', error); + }; + + handshake.initiate(); + }; + Peer.prototype.close = function close() { + this.broker.disconnect(); + }; + Peer.E = E; + + return Peer; + +})();
\ No newline at end of file |