/* * jQuery Stream 1.2 * Comet Streaming JavaScript Library * http://code.google.com/p/jquery-stream/ * * Copyright 2011, Donghwan Kim * Licensed under the Apache License, Version 2.0 * http://www.apache.org/licenses/LICENSE-2.0 * * Compatible with jQuery 1.5+ */ (function($, undefined) { var // Stream object instances instances = {}, // Streaming agents agents = {}, // HTTP Streaming transports transports = {}, // Does the throbber of doom exist? throbber = $.browser.webkit && !$.isReady; // Once the window is fully loaded, the throbber of doom will not be appearing if (throbber) { $(window).load(function() { throbber = false; }); } // Stream is based on The WebSocket API // W3C Working Draft 19 April 2011 - http://www.w3.org/TR/2011/WD-websockets-20110419/ $.stream = function(url, options) { // Returns the first Stream in the document if (!arguments.length) { for (var i in instances) { return instances[i]; } return null; } // Stream to which the specified url or alias is mapped var instance = instances[url]; if (!options) { return instance || null; } else if (instance && instance.readyState < 3) { return instance; } var // Stream object stream = { // URL to which to connect url: url, // Merges options options: $.stream.setup({}, options), // The state of stream // 0: CONNECTING, 1: OPEN, 2: CLOSING, 3: CLOSED readyState: 0, // Fake send send: function() {}, // Fake close close: function() {} }, match = /^(http|ws)s?:/.exec(stream.url), open = function() { // Delegates open process agents[stream.options.type](stream); }; // Stream type if (match) { stream.options.type = match[1]; } // Makes arrays of event handlers for (var i in {open: 1, message: 1, error: 1, close: 1}) { stream.options[i] = $.makeArray(stream.options[i]); } // The url and alias are a identifier of this instance within the document instances[stream.url] = stream; if (stream.options.alias) { instances[stream.options.alias] = stream; } // Deals with the throbber of doom if (stream.options.type === "ws" || !throbber) { open(); } else { switch (stream.options.throbber.type || stream.options.throbber) { case "lazy": $(window).load(function() { setTimeout(open, stream.options.throbber.delay || 50); }); break; case "reconnect": open(); $(window).load(function() { if (stream.readyState === 0) { stream.options.open.push(function() { stream.options.open.pop(); setTimeout(reconnect, 10); }); } else { reconnect(); } function reconnect() { stream.options.close.push(function() { stream.options.close.pop(); setTimeout(function() { $.stream(stream.url, stream.options); }, stream.options.throbber.delay || 50); }); var reconn = stream.options.reconnect; stream.close(); stream.options.reconnect = reconn; } }); break; } } return stream; }; $.extend($.stream, { version: "1.2", // Logic borrowed from jQuery.ajaxSetup setup: function(target, options) { if (!options) { options = target; target = $.extend(true, $.stream.options, options); } else { $.extend(true, target, $.stream.options, options); } for (var field in {context: 1, url: 1}) { if (field in options) { target[field] = options[field]; } else if (field in $.stream.options) { target[field] = $.stream.options[field]; } } return target; }, options: { // Stream type type: window.WebSocket ? "ws" : "http", // Whether to automatically reconnect when stream closed reconnect: true, // Whether to trigger global stream event handlers global: true, // Only for WebKit throbber: "lazy", // Message data type dataType: "text", // Message data converters converters: { text: window.String, json: $.parseJSON, xml: $.parseXML } // Additional parameters for GET request // openData: null, // WebSocket constructor argument // protocols: null, // XDomainRequest transport // enableXDR: false, // rewriteURL: null // Polling interval // operaInterval: 0 // iframeInterval: 0 } }); $.extend(agents, { // WebSocket wrapper ws: function(stream) { if (!window.WebSocket) { return; } var // Absolute WebSocket URL url = prepareURL(getAbsoluteURL(stream.url).replace(/^http/, "ws"), stream.options.openData), // WebSocket instance ws = stream.options.protocols ? new window.WebSocket(url, stream.options.protocols) : new window.WebSocket(url); // WebSocket event handlers $.extend(ws, { onopen: function(event) { stream.readyState = 1; trigger(stream, event); }, onmessage: function(event) { trigger(stream, $.extend({}, event, {data: stream.options.converters[stream.options.dataType](event.data)})); }, onerror: function(event) { stream.options.reconnect = false; trigger(stream, event); }, onclose: function(event) { var readyState = stream.readyState; stream.readyState = 3; trigger(stream, event); // Reconnect? if (stream.options.reconnect && readyState !== 0) { $.stream(stream.url, stream.options); } } }); // Overrides send and close $.extend(stream, { send: function(data) { if (stream.readyState === 0) { $.error("INVALID_STATE_ERR: Stream not open"); } ws.send(typeof data === "string" ? data : param(data)); }, close: function() { if (stream.readyState < 2) { stream.readyState = 2; stream.options.reconnect = false; ws.close(); } } }); }, // HTTP Streaming http: function(stream) { var // Transport transportFn, transport, // Low-level request and response handler handleOpen, handleMessage, handleSend, // Latch for AJAX sending, // Data queue dataQueue = [], // Helper object for parsing response message = { // The index from which to start parsing index: 0, // The temporary data data: "" }; // Chooses a proper transport transportFn = transports[ // xdr stream.options.enableXDR && window.XDomainRequest ? "xdr" : // iframe window.ActiveXObject ? "iframe" : // xhr window.XMLHttpRequest ? "xhr" : null]; if (!transportFn) { return; } // Default response handler handleOpen = stream.options.handleOpen || function(text, message, stream) { // The top of the response is made up of the id and padding // optional identifier within the server stream.id = text.substring(0, text.indexOf(";")); // message.index = text.indexOf(";", stream.id.length + ";".length) + ";".length; // tonyg 20120525: If the current chunk doesn't include the end-of-padding marker, // don't accept the header until it does. var semiPos = text.indexOf(";", stream.id.length + 1); if (semiPos < 0) { return false; } message.index = semiPos + 1; }; handleMessage = stream.options.handleMessage || function(text, message) { // Response could contain a single message, multiple messages or a fragment of a message // default message format is message-size ; message-data ; if (message.size == null) { // Checks a semicolon of size part var sizeEnd = text.indexOf(";", message.index); if (sizeEnd < 0) { return false; } message.size = +text.substring(message.index, sizeEnd); // index: sizeEnd + ";".length, message.index = sizeEnd + 1; } var data = text.substr(message.index, message.size - message.data.length); message.data += data; message.index += data.length; // Has stream message been completed? if (message.size !== message.data.length) { return false; } // Checks a semicolon of data part var dataEnd = text.indexOf(";", message.index); if (dataEnd < 0) { return false; } // message.index = dataEnd + ";".length; message.index = dataEnd + 1; // Completes parsing delete message.size; }; // Default request handler handleSend = stream.options.handleSend || function(type, options, stream) { var metadata = {"metadata.id": stream.id, "metadata.type": type}; options.data = // Close type === "close" ? param(metadata) : // Send // converts data if not already a string ((typeof options.data === "string" ? options.data : param(options.data)) + "&" + param(metadata)); }; transport = transportFn(stream, { response: function(text) { if (stream.readyState === 0) { if (handleOpen(text, message, stream) === false) { return; } stream.readyState = 1; trigger(stream, "open"); } for (;;) { if (handleMessage(text, message, stream) === false) { return; } if (stream.readyState < 3) { // Pseudo MessageEvent trigger(stream, "message", { // Converts the data type data: stream.options.converters[stream.options.dataType](message.data), origin: "", lastEventId: "", source: null, ports: null }); } // Resets the data message.data = ""; } }, close: function(isError) { var readyState = stream.readyState; stream.readyState = 3; if (isError) { // Prevents reconnecting stream.options.reconnect = false; // If establishing a connection fails, fires the close event instead of the error event if (readyState === 0) { // Pseudo CloseEvent trigger(stream, "close", { wasClean: false, code: null, reason: "" }); } else { trigger(stream, "error"); } } else { // Pseudo CloseEvent trigger(stream, "close", { // Presumes that the stream closed cleanly wasClean: true, code: null, reason: "" }); // Reconnect? if (stream.options.reconnect) { $.stream(stream.url, stream.options); } } } }, message); transport.open(); // Overrides send and close $.extend(stream, { send: function(data) { if (stream.readyState === 0) { $.error("INVALID_STATE_ERR: Stream not open"); } // Pushes the data into the queue dataQueue.push(data); if (!sending) { sending = true; // Performs an Ajax iterating through the data queue (function post() { if (stream.readyState === 1 && dataQueue.length) { var options = {url: stream.url, type: "POST", data: dataQueue.shift()}; if (handleSend("send", options, stream) !== false) { $.ajax(options).complete(post); } else { post(); } } else { sending = false; } })(); } }, close: function() { // Do nothing if the readyState is in the CLOSING or CLOSED if (stream.readyState < 2) { stream.readyState = 2; var options = {url: stream.url, type: "POST"}; if (handleSend("close", options, stream) !== false) { // Notifies the server $.ajax(options); } // Prevents reconnecting stream.options.reconnect = false; transport.close(); } } }); } }); $.extend(transports, { // XMLHttpRequest: Modern browsers except Internet Explorer xhr: function(stream, handler, message) { var stop, polling, preStatus, xhr = new window.XMLHttpRequest(); xhr.onreadystatechange = function() { switch (xhr.readyState) { // Handles open and message event case 3: if (xhr.status !== 200) { return; } handler.response(xhr.responseText); // For Opera if ($.browser.opera && !polling) { polling = true; stop = iterate(function() { if (xhr.readyState === 4) { return false; } if (xhr.responseText.length > message.index) { handler.response(xhr.responseText); } }, stream.options.operaInterval); } break; // Handles error or close event case 4: // HTTP status 0 could mean that the request is terminated by abort method // but it's not error in Stream object handler.close(xhr.status !== 200 && preStatus !== 200); break; } }; return { open: function() { xhr.open("GET", prepareURL(stream.url, stream.options.openData)); xhr.send(); }, close: function() { if (stop) { stop(); } // Saves status try { preStatus = xhr.status; } catch (e) {} xhr.abort(); } }; }, // Hidden iframe: Internet Explorer iframe: function(stream, handler, message) { var stop, closed, onload = function() { if (!closed) { closed = true; handler.close(); } }, doc = new window.ActiveXObject("htmlfile"); doc.open(); doc.close(); return { open: function() { var iframe = doc.createElement("iframe"); iframe.src = prepareURL(stream.url, stream.options.openData); doc.body.appendChild(iframe); // For the server to respond in a consistent format regardless of user agent, we polls response text var cdoc = iframe.contentDocument || iframe.contentWindow.document; stop = iterate(function() { if (!cdoc.documentElement) { return; } // Detects connection failure if (cdoc.readyState === "complete") { try { $.noop(cdoc.fileSize); } catch(e) { handler.close(true); return false; } } var response = cdoc.body.lastChild, readResponse = function() { // Clones the element not to disturb the original one var clone = response.cloneNode(true); // If the last character is a carriage return or a line feed, IE ignores it in the innerText property // therefore, we add another non-newline character to preserve it clone.appendChild(cdoc.createTextNode(".")); var text = clone.innerText; return text.substring(0, text.length - 1); }; // To support text/html content type if (!$.nodeName(response, "pre")) { // Injects a plaintext element which renders text without interpreting the HTML and cannot be stopped // it is deprecated in HTML5, but still works var head = cdoc.head || cdoc.getElementsByTagName("head")[0] || cdoc.documentElement, script = cdoc.createElement("script"); script.text = "document.write('')"; head.insertBefore(script, head.firstChild); head.removeChild(script); // The plaintext element will be the response container response = cdoc.body.lastChild; } // Handles open event handler.response(readResponse()); // Handles message and close event stop = iterate(function() { var text = readResponse(); if (text.length > message.index) { handler.response(text); // Empties response every time that it is handled response.innerText = ""; message.index = 0; } if (cdoc.readyState === "complete") { onload(); return false; } }, stream.options.iframeInterval); return false; }); }, close: function() { if (stop) { stop(); } doc.execCommand("Stop"); onload(); } }; }, // XDomainRequest: Optionally Internet Explorer 8+ xdr: function(stream, handler) { var xdr = new window.XDomainRequest(), rewriteURL = stream.options.rewriteURL || function(url) { // Maintaining session by rewriting URL // http://stackoverflow.com/questions/6453779/maintaining-session-by-rewriting-url var rewriters = { JSESSIONID: function(sid) { return url.replace(/;jsessionid=[^\?]*|(\?)|$/, ";jsessionid=" + sid + "$1"); }, PHPSESSID: function(sid) { return url.replace(/\?PHPSESSID=[^&]*&?|\?|$/, "?PHPSESSID=" + sid + "&").replace(/&$/, ""); } }; for (var name in rewriters) { // Finds session id from cookie var matcher = new RegExp("(?:^|;\\s*)" + encodeURIComponent(name) + "=([^;]*)").exec(document.cookie); if (matcher) { return rewriters[name](matcher[1]); } } return url; }; // Handles open and message event xdr.onprogress = function() { handler.response(xdr.responseText); }; // Handles error event xdr.onerror = function() { handler.close(true); }; // Handles close event var onload = xdr.onload = function() { handler.close(); }; return { open: function() { xdr.open("GET", prepareURL(rewriteURL(stream.url), stream.options.openData)); xdr.send(); }, close: function() { xdr.abort(); onload(); } }; } }); // Closes all stream when the document is unloaded // this works right only in IE $(window).bind("unload.stream", function() { for (var url in instances) { instances[url].close(); delete instances[url]; } }); $.each("streamOpen streamMessage streamError streamClose".split(" "), function(i, o) { $.fn[o] = function(f) { return this.bind(o, f); }; }); // Works even in IE6 function getAbsoluteURL(url) { var div = document.createElement("div"); div.innerHTML = "<a href='" + url + "'/>"; return div.firstChild.href; } function trigger(stream, event, props) { event = event.type ? event : $.extend($.Event(event), {bubbles: false, cancelable: false}, props); var handlers = stream.options[event.type], applyArgs = [event, stream]; // Triggers local event handlers for (var i = 0, length = handlers.length; i < length; i++) { handlers[i].apply(stream.options.context, applyArgs); } if (stream.options.global) { // Triggers global event handlers $.event.trigger("stream" + event.type.substring(0, 1).toUpperCase() + event.type.substring(1), applyArgs); } } function prepareURL(url, data) { // Converts data into a query string if (data && typeof data !== "string") { data = param(data); } // Attaches a time stamp to prevent caching var ts = $.now(), ret = url.replace(/([?&])_=[^&]*/, "$1_=" + ts); return ret + (ret === url ? (/\?/.test(url) ? "&" : "?") + "_=" + ts : "") + (data ? ("&" + data) : ""); } function param(data) { return $.param(data, $.ajaxSettings.traditional); } function iterate(fn, interval) { var timeoutId; // Though the interval is 0 for real-time application, there is a delay between setTimeout calls // For detail, see https://developer.mozilla.org/en/window.setTimeout#Minimum_delay_and_timeout_nesting interval = interval || 0; (function loop() { timeoutId = setTimeout(function() { if (fn() === false) { return; } loop(); }, interval); })(); return function() { clearTimeout(timeoutId); }; } })(jQuery);