/** * Copyright 2009 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS-IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var host = window.location.host; function WebSocket(id) { var self = this; var socket = this; var version = 2; var timeouts = {}; this.onopen = function() { } this.onclosed = function() { } this.onmessage = function() { } this.onhiccup = function() { } this.onlogmessage = function() { } this.CONNECTING = 0; this.OPEN = 1; this.CLOSED = 2; this.readyState = -1; var hiccupsLastMinute = 0; var hiccupResetInterval = setInterval(function() { hiccupsLastMinute = 0; if (self.readyState == self.CLOSED) clearInterval(hiccupResetInterval); }, 60*1000); var isHiccuping = false; function hiccup(channel) { if (channel != officialChannel && channel != self) return; if (isHiccuping) return; log("hiccup: "+channel.name); if (hiccupsLastMinute++ > 10) { doDisconnect({reconnect: true, reason: "Too many hiccups!"}); return; } closeAllChannels(); timeout(timeouts, "hiccup", 15000, function() { isHiccuping = false; doDisconnect({reconnect: false, reason: "Couldn't contact server to hiccup."}); }); isHiccuping = true; function tryHiccup() { if (! isHiccuping) return; self.onhiccup({connected: false}); log("trying hiccup"); timeout(timeouts, "singleHiccup", 5000, function() { tryHiccup(); }); simpleXhr('post', postPath(), true, [{key: "oob", value: "hiccup"}], function(sc, msg) { if (! isHiccuping) return; if (msg.substring(0, "restart-fail".length) == "restart-fail") { doDisconnect({reconnect: true, reason: "Server restarted or socket timed out on server."}); } else if (sc != 200 || msg.substring(0, 2) != "ok") { log("Failed to hiccup with error: "+sc+" / "+msg); setTimeout(tryHiccup, 500); } else { isHiccuping = false; timeouts.singleHiccup(); timeouts.hiccup(); doConnect(); } }); } tryHiccup(); } function closeAllChannels() { for (var i in activeChannels) { if (activeChannels.hasOwnProperty(i)) { activeChannels[i].disconnect(); } } officialChannel = undefined; } function doDisconnect(obj, silent, sync) { log("disconnected: "+obj.reason+" / "+(obj.data !== undefined ? "data: "+obj.data : "")); logAll(); closeAllChannels(); if (longPollingIFrame && longPollingIFrame.div) { longPollingIFrame.div.innerHTML = ""; } if (self.readyState != self.CLOSED) { self.readyState = self.CLOSED; if (! silent) { postSingleMessageNow(true, "kill:"+obj.reason, sync, true); } self.onclosed(obj); } } this.disconnect = function(sync) { doDisconnect({reason: "Closed by client."}, false, sync); } function doBasicConnect() { var type = getBasicChannel(); log("basic connect on type: "+type); var channel = activeChannels[type] = new channelConstructors[type](); channel.connect(); } function doOtherConnect() { var channels = getOtherChannels(); var channel; var type; for (var i = 0; i < channels.length; ++i) { type = channels[i]; log("other connect on type: "+type); channel = activeChannels[type] = new channelConstructors[type](); channel.connect(); } } function doConnect() { log("doing connect!"); timeout(timeouts, "connect", 15000, function() { doDisconnect({reconnect: false, reason: "Timeout connecting to server: no channel type was able to connect."}); }); doBasicConnect(); } this.connect = function() { log("socket connecting: "+id); doConnect(); } // util function nicetime() { return Math.floor((new Date()).valueOf() / 100) % 10000000; } function log(s) { self.onlogmessage("(comet @t: "+nicetime()+") "+s); } function logAll() { log(self.describe()) } this.describe = function() { function describeChannels() { out = []; for (var i in activeChannels) { if (activeChannels.hasOwnProperty(i)) { out.push(i+": "+activeChannels[i].describe()); } } return "[ "+out.join(", ")+" ]"; } return ("socket state: { id: "+id+", readyState: "+self.readyState+", isHiccuping: "+isHiccuping+", timeouts: "+describeTimeouts(timeouts)+", officialChannel: "+(officialChannel?officialChannel.name:"none")+", channels: "+describeChannels()+", isPosting: "+isPosting+", lastReceivedSeqNumber: "+lastReceivedSeqNumber+", lastPost: "+lastPost+", postTimeouts: "+describeTimeouts(postTimeouts)+", channelSeq: "+channelSeq+" }"); } function wrapMethod(obj, method) { return function() { var arr = []; for (var i=0; i < arguments.length; i++) { arr.push(arguments[i]); } method.apply(obj, arr); } } var _wm = wrapMethod; // cb should take statusCode, responseText, and optionally request function simpleXhr(method, uri, async, params, cb, makeXhr) { // log("making simple Xhr: "+[method, uri, async, params].join(", ")); var request = (makeXhr || newRequestObject)(); request.open(method, uri, async); if (async) { request.onreadystatechange = function() { if (request.readyState != 4) return; var status; var responseText; try { status = request.status; responseText = request.responseText; } catch (e) { /* absorb ff error accessing request properties */ } cb(status, responseText, request); } } var data = null; if (params) { data = []; for (var i = 0; i < params.length; ++i) { data.push(encodeURIComponent(params[i].key)+"="+encodeURIComponent(params[i].value)); } data = data.join("&"); request.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=utf-8"); } try { request.send(data); } catch (e) { request.abort(); cb(500, "Error sending data!", request); } if (! async) { var status; var responseText; try { status = request.status; responseText = request.responseText; } catch (e) { /* absorb ff error accessing request properties */ } cb(status, responseText, request); } return request; } var timeout_noop = function() { } function timeout(timeoutObject, timeoutName, millis, timeoutCallback) { function clearIt(timeoutObject, timeoutName) { if (timeoutObject[timeoutName]) { timeoutObject[timeoutName](); timeoutObject[timeoutName] = timeout_noop; } } var timeoutId = setTimeout(function() { clearIt(timeoutObject, timeoutName); timeoutCallback(); }, millis); var f = function() { clearTimeout(timeoutId); } clearIt(timeoutObject, timeoutName); timeoutObject[timeoutName] = f; return f; } // handling messages var lastReceivedSeqNumber = 0; function dataHandler(msg) { if (msg.seqNumber > lastReceivedSeqNumber+1) { log("bad sequence number. expecting: "+(lastReceivedSeqNumber+1)+", got: "+msg.seqNumber); hiccup(self); return false; } if (msg.seqNumber < lastReceivedSeqNumber+1) return true; lastReceivedSeqNumber = msg.seqNumber; if (! msg.isControl) { self.onmessage({ data: msg.content }); return true; } else { if (msg.content == "kill") { doDisconnect({reconnect: false, reason: "Killed by server."}); return false; } } } // client-server comm var postPath = function() { return "%contextPath%/post?r="+randomVar()+"&v="+version+"&id="+id+"&seq="+lastReceivedSeqNumber; } function SimpleQueue() { var base = []; var head = 0; var tail = 0; this.offer = function(data) { base[tail++] = data; } this.poll = function() { if (this.length() > 0) { var n = base[head]; delete base[head++]; return n; } } this.clear = function() { head = 0; tail = 0; var oldBase = base; base = []; return oldBase; } this.length = function() { return tail - head; } } var outgoingMessageQueue = new SimpleQueue(); var isPosting = false; var postTimeouts = {}; var lastPost; function postSingleMessageNow(isControl, data, sync, force, cb) { doPostMessages([{oob: isControl, data: data, cb: cb}], sync, force) } function doPostMessages(messages, sync, force, cb) { if (! force && self.readyState == self.CLOSED) return; if (messages.length == 0) { if (cb) cb(); return; } var data = []; var callbacks = []; for (var i = 0; i < messages.length; ++i) { data.push({key: (messages[i].oob ? "oob" : "m"), value: messages[i].data}); if (messages[i].cb) callbacks.push(messages[i].cb); } function postFailed(sc, msg, req, src) { var str = ""; try { str = sc + ": "+req.statusText+" - "+msg+" ("+src+")"; } catch (e) { /* absorb potential Firefox error accessing req */ } doDisconnect({reconnect: true, reason: "Posting message failed.", data: str}); for (var i = 0; i < callbacks.length; ++i) { callbacks[i](false, str); } } function postCallback(sc, msg, request) { postTimeouts.post(); if (sc != 200 || msg.substring(0, 2) != "ok") { postFailed(sc, msg, request, "1"); } else { for (var i = 0; i < callbacks.length; ++i) { callbacks[i](true); } if (cb) cb(); } } timeout(postTimeouts, "post", 15000, function() { doDisconnect({reconnect: true, reason: "Posting message timed out."}); }); simpleXhr('post', postPath(), ! sync, data, postCallback); } function postPendingMessages() { if (isPosting == true) return; var messages = outgoingMessageQueue.clear(); if (messages.length == 0) { return; } isPosting = true; doPostMessages(messages, false, false, function() { isPosting = false; setTimeout(postPendingMessages, 0); }); lastPost = nicetime(); } this.postMessage = function(data, cb) { if (self.readyState != self.OPEN) { return; } outgoingMessageQueue.offer({data: data, cb: cb}); setTimeout(function() { postPendingMessages() }, 0); } // transports function getValidChannels() { var channels = []; for (var i = 0; i < validChannels.length; ++i) { var type = validChannels[i]; if (window.location.hash.length > 0) { if (window.location.hash != "#"+type) { continue; } } if ($ && $.browser.opera && type != 'shortpolling' && type != 'streaming') { continue; } channels.push(type); } return channels; } function getBasicChannel() { return getValidChannels()[0]; } function getOtherChannels() { return getValidChannels().slice(1); } var officialChannel; this.getTransportType = function() { return (officialChannel ? officialChannel.name : "none"); } var validChannels = "%acceptableChannelTypes%"; var canUseSubdomains = "%canUseSubdomains%"; var activeChannels = {}; var channelConstructors = { shortpolling: ShortPollingChannel, longpolling: LongPollingChannel, streaming: StreamingChannel } function describeTimeouts(timeouts) { var out = []; for (var i in timeouts) { if (timeouts.hasOwnProperty(i)) { out.push(i+": "+(timeouts[i] == timeout_noop ? "unset" : "set")); } } return "{ "+out.join(", ")+" }"; } var channelSeq = 1; function notifyConnect(channel) { timeouts.connect(); if (! officialChannel || channel.weight > officialChannel.weight) { log("switching to use channel: "+channel.name); var oldChannel = officialChannel; officialChannel = channel; setTimeout(function() { postSingleMessageNow(true, "useChannel:"+(channelSeq++)+":"+channel.name, false, false, function(success, msg) { if (success) { if (oldChannel) { oldChannel.disconnect(); } else { // there was no old channel, so try connecting the other channels. doOtherConnect(); } if (self.readyState != self.OPEN) { self.readyState = self.OPEN; self.onopen({}); } else { self.onhiccup({connected: true}); } } else { doDisconnect({reconnect: true, reason: "Failed to select channel on server.", data: msg}); } }); }, 0); return true; } else { return false; } } function randomVar() { return String(Math.round(Math.random()*1e12)); } function channelPath() { return "%contextPath%/channel?v="+version+"&r="+randomVar()+"&id="+id; } function newRequestObject() { var xmlhttp=false; try { xmlhttp = (window.ActiveXObject && new ActiveXObject("Msxml2.XMLHTTP")) } catch (e) { try { xmlhttp = (window.ActiveXObject && new ActiveXObject("Microsoft.XMLHTTP")); } catch (E) { xmlhttp = false; } } if (!xmlhttp && typeof XMLHttpRequest!='undefined') { try { xmlhttp = new XMLHttpRequest(); } catch (e) { xmlhttp=false; } } if (!xmlhttp && window.createRequest) { try { xmlhttp = window.createRequest(); } catch (e) { xmlhttp=false; } } return xmlhttp } function DataFormatError(message) { this.message = message; } function readMessage(data, startIndex) { if (! startIndex) startIndex = 0; var sep = data.indexOf(":", startIndex); if (sep < 0) return; // don't have all the bytes for this yet. var chars = Number(data.substring(startIndex, sep)); if (isNaN(chars)) throw new DataFormatError("Bad length: "+data.substring(startIndex, sep)); if (data.length < sep+1+chars) return; // don't have all the bytes for this yet. var msg = data.substr(sep+1, chars); return { message: msg, lastConsumedChar: sep+1+chars } } function iframeReader(data, startIndex) { if (startIndex == 0) return { message: data, lastConsumedChar: data.length } } function parseWireFormat(data, startIndex, reader) { if (! startIndex) startIndex = 0; var msgs = []; var readThroughIndex = startIndex; while (true) { var msgObj = (reader || readMessage)(data, readThroughIndex) if (! msgObj) break; readThroughIndex = msgObj.lastConsumedChar; var msg = msgObj.message; var split = msg.split(":"); if (split[0] == 'oob') { msgs.push({oob: split.slice(1).join(":")}); continue; } var seq = Number(split[0]); if (isNaN(seq)) throw new DataFormatError("Bad sequence number: "+split[0]); var control = Number(split[1]); if (isNaN(control)) throw new DataFormatError("Bad control: "+split[1]); var msgContent = split.slice(2).join(":"); msgs.push({seqNumber: seq, isControl: (control == 1), content: msgContent}); } return { messages: msgs, lastConsumedChar: readThroughIndex } } function handleMessages(data, cursor, channel, reader) { try { messages = parseWireFormat(data, cursor, reader); } catch (e) { if (e instanceof DataFormatError) { log("Data format error: "+e.message); hiccup(channel); return; } else { log(e.toString()+" on line: "+e.lineNumber); } } for (var i=0; i < messages.messages.length; i++) { var oob = messages.messages[i].oob; if (oob) { if (oob == "restart-fail") { doDisconnect({reconnect: true, reason: "Server restarted or socket timed out on server."}); return; } } else { if (! dataHandler(messages.messages[i])) break; } } return messages.lastConsumedChar; } function ShortPollingChannel() { this.weight = 0; this.name = "shortpolling"; this.isConnected = false; this.isClosed = false; this.request; this.clearRequest = function() { if (this.request) { this.request.abort(); this.request = null; } } this.timeouts = {}; this.describe = function() { return "{ isConnected: "+this.isConnected+", isClosed: "+this.isClosed+", timeouts: "+describeTimeouts(this.timeouts)+", request: "+(this.request?"set":"not set")+" }" } this.pollDataHandler = function(sc, response, request) { if (request.readyState != 4) return; if (this.timeouts.poll) this.timeouts.poll(); var messages; if (! this.isConnected) { this.timeouts.connectAttempt(); if (sc != 200) { log(this.name+" connect failed: "+sc+" / "+response); setTimeout(_wm(this, this.attemptConnect), 500); return; } var msg = (response ? readMessage(response) : undefined); if (msg && msg.message == "oob:ok") { this.timeouts.initialConnect(); this.isConnected = true; log(this.name+" transport connected!"); if (! notifyConnect(this)) { // there are better options connected. log(this.name+" transport not chosen for activation."); this.disconnect(); return; } this.doPoll(); return; } else { log(this.name+" connect didn't get ok: "+sc+" / "+response); setTimeout(_wm(this, this.attemptConnect), 500); return; } } var chars = handleMessages(request.responseText, 0, this); if (sc != 200 || ((! chars) && this.emptyResponseBad)) { hiccup(this); } setTimeout(_wm(this, this.doPoll), this.pollDelay); this.clearRequest(); } this.keepRetryingConnection = true; this.cancelConnect = function() { this.clearRequest(); this.keepRetryingConnection = false; } this.cancelPoll = function() { this.clearRequest(); log("poll timed out."); hiccup(this); } this.doPoll = function() { if (this.isClosed) return; timeout(this.timeouts, "poll", this.pollTimeout, _wm(this, this.cancelPoll)); this.request = simpleXhr('GET', channelPath()+"&channel="+this.name+"&seq="+lastReceivedSeqNumber+this.pollParams(), true, undefined, _wm(this, this.pollDataHandler), this.xhrGenerator); } this.pollParams = function() { return ""; } this.pollTimeout = 5000; this.pollDelay = 500; this.attemptConnect = function() { if (! this.keepRetryingConnection) return; log(this.name+" attempting connect"); this.clearRequest(); timeout(this.timeouts, "connectAttempt", 5000, _wm(this, this.attemptConnect)); this.request = simpleXhr('GET', channelPath()+"&channel="+this.name+"&new=yes&create="+(socket.readyState == socket.OPEN ? "no" : "yes")+"&seq="+lastReceivedSeqNumber, true, undefined, _wm(this, this.pollDataHandler), this.xhrGenerator); } this.connect = function() { this.attemptConnect(); timeout(this.timeouts, "initialConnect", 15000, _wm(this, this.cancelConnect)); } this.disconnect = function() { log(this.name+" disconnected"); this.isClosed = true; this.clearRequest(); } } function StreamingChannel() { this.weight = 2; this.name = "streaming"; var self = this; var isConnected = false; var request; function clearRequest() { if (request) { request.abort(); request = null; if (theStream) theStream = null; if (ifrDiv) { ifrDiv.innerHTML = ""; ifrDiv = null; } } } var isClosed = false; var timeouts = {}; var cursor = 0; this.describe = function() { return "{ isConnected: "+isConnected+", isClosed: "+isClosed+", timeouts: "+describeTimeouts(timeouts)+", request: "+(request?"set":"not set")+", cursor: "+cursor+" }"; }; function connectOk() { isConnected = true; timeouts.initialConnect(); if (! notifyConnect(self)) { log("streaming transport not chosen for activation"); self.disconnect(); return; } } function streamDataHandler() { if (timeouts.data) timeouts.data(); if (isClosed) return; try { if (! request.responseText) return; } catch (e) { return; } if (! isConnected) { var msg = readMessage(request.responseText, cursor); if (! msg) return; cursor = msg.lastReceivedSeqNumber; if (msg.message == "oob:ok") { connectOk(); } else { log("stream: incorrect channel connect message:"+msg.message); self.disconnect(); return; } } else { cursor = handleMessages(request.responseText, cursor, self); } if (! request || request.readyState == 4) { clearRequest(); if (isConnected) { log("stream connection unexpectedly closed."); hiccup(self); return; } } timeout(timeouts, "data", 60*1000, function() { hiccup(self); }); } function iframeDataHandler(data) { if (isClosed) return; if (! isConnected) { if (data == "oob:ok") { connectOk(); } else { log("iframe stream: unexpected data on connect - "+data); } } else { handleMessages(data, 0, self, iframeReader); } } function cancelConnect() { isClosed = true; clearRequest(); log("stream: failed to connect."); } // IE Stuff. var theStream; var ifrDiv; var iframeTestCount = 0; function testIframe() { var state; try { state = ifrDiv.firstChild.readyState; } catch (e) { hiccup(self); return; } if (state == 'interactive' || iframeTestCount > 10) { try { var tmp = ifrDiv.firstChild.contentWindow.document.getElementById("thebody") } catch (e) { hiccup(self); } } else { iframeTestCount++; setTimeout(testIframe, 500); } } this.connect = function() { timeout(timeouts, "initialConnect", 15000, cancelConnect) if (canUseSubdomains) { var streamurl = "//"+randomVar()+".comet."+host+channelPath()+"&channel=streaming&type=iframe&new=yes&create="+(socket.readyState == socket.OPEN ? "no" : "yes")+"&seq="+lastReceivedSeqNumber; log("stream to: "+streamurl); if ($ && $.browser.opera) { // set up the opera stream; requires jquery because, why not? ifrDiv = $('
').get(0); $('body').append(ifrDiv); window.comet = { pass_data: iframeDataHandler, disconnect: function() { hiccup(self); } } $(ifrDiv).append($("")); iframeTestCount = 0; setTimeout(testIframe, 2000); // if event-source supported disconnect notifications, fuck yeah we'd use it. // theStream = $(''); // var streamurl = channelPath()+"&channel=streaming&type=opera&new=yes&create="+(socket.readyState == socket.OPEN ? "no" : "yes")+"&seq="+lastReceivedSeqNumber; // theStream.get(0).addEventListener('message', function(event) { // iframeDataHandler(event.data); // }, false); // theStream.attr('src', streamurl); log("stream connect sent!"); return; } try { // TODO: remove reference to both theStream and ifrDiv on unload! theStream = (window.ActiveXObject && new ActiveXObject("htmlfile")); if (theStream) { theStream.open(); theStream.write("f<\/title><\/head><body>") theStream.write("<s"+"cript>document.domain='"+document.domain+"';<\/s"+"cript>") theStream.write("<\/body><\/html>") theStream.close(); ifrDiv = theStream.createElement("div") theStream.appendChild(ifrDiv) theStream.parentWindow.comet = { pass_data: iframeDataHandler, disconnect: function() { hiccup(self); } } ifrDiv.innerHTML = "<iframe src='"+streamurl+"'></iframe>"; iframeTestCount = 0; setTimeout(testIframe, 2000); } } catch (e) { theStream = false } } else if ($ && $.browser.opera) { // opera thinks it can do a normal stream, but it can't. log("opera - not trying xhr"); return; } // End IE Stuff. if (! theStream) { request = newRequestObject(); request.open('get', channelPath()+"&channel=streaming&new=yes&create="+(socket.readyState == socket.OPEN ? "no" : "yes")+"&seq="+lastReceivedSeqNumber); request.onreadystatechange = streamDataHandler; try { request.send(null); } catch (e) { } } log("stream connect sent!"); } this.disconnect = function() { log("stream disconnected"); isClosed = true; clearRequest(); } log("new streamchannel"); } // long-polling related stuff. function iframePath(key) { return "//"+key+".comet."+host+"%contextPath%/xhrXdFrame"; } function createHiddenDiv() { if (! document.getElementById('newcomethidden')) { var d = document.createElement('div'); d.setAttribute('id', 'newcomethidden'); d.style.display = 'none'; document.body.appendChild(d); } return document.getElementById('newcomethidden'); } function ExtHostXHR(iframe) { this.open = function(method, uri, async) { this.method = method; this.uri = uri; this.async = async; } var headers = {}; this.setRequestHeader = function(name, value) { headers[name] = value; } this.send = function(data) { var self = this; this.xhr = iframe.iframe.contentWindow.doAction(this.method, this.uri, this.async, headers, data || null, function(status, response) { self.readyState = 4; self.status = status; self.responseText = response; self.onreadystatechange(); }); } this.abort = function() { if (this.xhr) iframe.contentWindow.doAbort(this.xhr); } } function createRequestIframe(cb) { var randomKey = randomVar(); try { var activeXControl = (window.ActiveXObject && new ActiveXObject("htmlfile")); var htmlfileDiv; if (activeXControl) { activeXControl.open(); activeXControl.write('<html><head><title>f'); activeXControl.write('document.domain=\''+document.domain+'\';'); activeXControl.write(''); activeXControl.close(); htmlfileDiv = activeXControl.createElement('div'); activeXControl.appendChild(htmlfileDiv); activeXControl.parentWindow["done_"+randomKey] = cb; htmlfileDiv.innerHTML = ""; return {iframe: htmlfileDiv.firstChild /* should be an iframe */, axc: activeXControl, div: htmlfileDiv}; } } catch (e) { activeXControl = false; } log("Not using IE setup."); var requestIframe = document.createElement('iframe'); createHiddenDiv().appendChild(requestIframe); window["done_"+randomKey] = function() { try { delete window["done_"+randomKey]; } catch (e) { }; cb(); } requestIframe.src = iframePath(randomKey); return {iframe: requestIframe}; } function createIframeRequestObject() { if (! longPollingIFrame) throw Error("WebSocket isn't properly set up!"); return new ExtHostXHR(longPollingIFrame); } var longPollingIFrame; function LongPollingChannel() { ShortPollingChannel.apply(this); // sets up other state. this.weight = 1; this.name = "longpolling"; this.pollDelay = 0; this.pollTimeout = 15000; this.pollParams = function() { return "&timeout="+(this.pollTimeout-5000); } var connect = this.connect; this.connect = function() { if (! longPollingIFrame) { longPollingIFrame = createRequestIframe(_wm(this, connect)); // specifically *not* this.connect. we want the old one! } else { connect.apply(this); } } this.xhrGenerator = createIframeRequestObject; this.emptyResponseBad = true; } }