var ShinyApp = function() { this.$socket = null; // Cached input values this.$inputValues = {}; // Output bindings this.$bindings = {}; // Cached values/errors this.$values = {}; this.$errors = {}; // Conditional bindings (show/hide element based on expression) this.$conditionals = {}; this.$pendingMessages = []; this.$activeRequests = {}; this.$nextRequestId = 0; this.$allowReconnect = false; }; (function() { this.connect = function(initialInput) { if (this.$socket) throw "Connect was already called on this application object"; $.extend(initialInput, { // IE8 and IE9 have some limitations with data URIs ".clientdata_allowDataUriScheme": typeof WebSocket !== 'undefined' }); this.$socket = this.createSocket(); this.$initialInput = initialInput; $.extend(this.$inputValues, initialInput); this.$updateConditionals(); }; this.isConnected = function() { return !!this.$socket; }; this.reconnect = function() { if (this.isConnected()) throw "Attempted to reconnect, but already connected."; this.$socket = this.createSocket(); this.$initialInput = $.extend({}, this.$inputValues); this.$updateConditionals(); }; this.createSocket = function () { var self = this; var createSocketFunc = exports.createSocket || function() { var protocol = 'ws:'; if (window.location.protocol === 'https:') protocol = 'wss:'; var defaultPath = window.location.pathname; // some older WebKit browsers return the pathname already decoded; // if we find invalid URL characters in the path, encode them if (!/^([$#!&-;=?-[\]_a-z~]|%[0-9a-fA-F]{2})+$/.test(defaultPath)) { defaultPath = encodeURI(defaultPath); // Bizarrely, QtWebKit requires us to encode these characters *twice* if (browser.isQt) { defaultPath = encodeURI(defaultPath); } } if (!/\/$/.test(defaultPath)) defaultPath += '/'; defaultPath += 'websocket/'; var ws = new WebSocket(protocol + '//' + window.location.host + defaultPath); ws.binaryType = 'arraybuffer'; // This flag indicates that reconnections are permitted by the server. // When Shiny is running with Shiny Server, this flag will be present // only on versions of SS that support reconnects. When running Shiny // locally, this is set to true, because the "server" always permits // reconnection. ws.allowReconnect = true; return ws; }; var socket = createSocketFunc(); var hasOpened = false; socket.onopen = function() { hasOpened = true; $(document).trigger({ type: 'shiny:connected', socket: socket }); exports.hideReconnectDialog(); socket.send(JSON.stringify({ method: 'init', data: self.$initialInput })); while (self.$pendingMessages.length) { var msg = self.$pendingMessages.shift(); socket.send(msg); } }; socket.onmessage = function(e) { self.dispatchMessage(e.data); }; // Called when a successfully-opened websocket is closed, or when an // attempt to open a connection fails. socket.onclose = function() { // These things are needed only if we've successfully opened the // websocket. if (hasOpened) { $(document).trigger({ type: 'shiny:disconnected', socket: socket }); self.$notifyDisconnected(); } self.$removeSocket(); // To try a reconnect, both the app (self.$allowReconnect) and the // server (socket.allowReconnect) must allow reconnections. if (self.$allowReconnect === true && socket.allowReconnect === true) { exports.showReconnectDialog(); self.$scheduleReconnect(); } else { $(document.body).addClass('disconnected'); } }; return socket; }; this.sendInput = function(values) { var msg = JSON.stringify({ method: 'update', data: values }); this.$sendMsg(msg); $.extend(this.$inputValues, values); this.$updateConditionals(); }; this.$notifyDisconnected = function() { // function to normalize hostnames var normalize = function(hostname) { if (hostname == "127.0.0.1") return "localhost"; else return hostname; }; // Send a 'disconnected' message to parent if we are on the same domin var parentUrl = (parent !== window) ? document.referrer : null; if (parentUrl) { // parse the parent href var a = document.createElement('a'); a.href = parentUrl; // post the disconnected message if the hostnames are the same if (normalize(a.hostname) == normalize(window.location.hostname)) { var protocol = a.protocol.replace(':',''); // browser compatability var origin = protocol + '://' + a.hostname; if (a.port) origin = origin + ':' + a.port; parent.postMessage('disconnected', origin); } } }; this.$removeSocket = function() { this.$socket = null; }; // Try to reconnect after one second this.$scheduleReconnect = function() { var self = this; setTimeout(function() { self.reconnect(); }, 1000); }; exports.showReconnectDialog = function() { // If there's already a reconnect dialog, don't add another if ($('.shiny-reconnect-dialog-wrapper').length > 0) return; var $dialog = $('
' + '
' + '' + '' + '
') .appendTo('body'); $(".shiny-reconnect-text").text("Trying to reconnect to new session"); var ndots = 1; var updateDots = function() { // Select from $dialog, so that if a separate function call adds a div // of the same class, we won't try to update that div's dots. This could // happen when Shiny Server adds its own reconnection dialog. var $dots = $dialog.find(".shiny-reconnect-dots"); // If the dots have been removed, exit and don't reschedule this function. if ($dots.length === 0) return; // Create the string for number of dots ndots = (ndots-1) % 3 + 1; var dotstr = ""; for (var i=0; i 0) { var el = $obj[0]; var evt = jQuery.Event('shiny:updateinput'); evt.message = message[i].message; evt.binding = inputBinding; $(el).trigger(evt); if (!evt.isDefaultPrevented()) inputBinding.receiveMessage(el, evt.message); } } }); addMessageHandler('javascript', function(message) { /*jshint evil: true */ eval(message); }); addMessageHandler('console', function(message) { for (var i = 0; i < message.length; i++) { if (console.log) console.log(message[i]); } }); addMessageHandler('progress', function(message) { if (message.type && message.message) { var handler = progressHandlers[message.type]; if (handler) handler.call(this, message.message); } }); addMessageHandler('notification', function(message) { if (message.type === 'show') exports.notifications.show(message.message); else if (message.type === 'remove') exports.notifications.remove(message.message); else throw('Unkown notification type: ' + message.type); }); addMessageHandler('response', function(message) { var requestId = message.tag; var request = this.$activeRequests[requestId]; if (request) { delete this.$activeRequests[requestId]; if ('value' in message) request.onSuccess(message.value); else request.onError(message.error); } }); addMessageHandler('allowReconnect', function(message) { if (!(message === true || message === false)) { throw "Invalid value for allowReconnect: " + message; } this.$allowReconnect = message; }); addMessageHandler('custom', function(message) { // For old-style custom messages - should deprecate and migrate to new // method if (exports.oncustommessage) { exports.oncustommessage(message); } // Send messages.foo and messages.bar to appropriate handlers this._sendMessagesToHandlers(message, customMessageHandlers, customMessageHandlerOrder); }); addMessageHandler('config', function(message) { this.config = message; }); addMessageHandler('busy', function(message) { if (message === 'busy') { $(document.documentElement).addClass('shiny-busy'); $(document).trigger('shiny:busy'); } else if (message === 'idle') { $(document.documentElement).removeClass('shiny-busy'); $(document).trigger('shiny:idle'); } }); addMessageHandler('recalculating', function(message) { if (message.hasOwnProperty('name') && message.hasOwnProperty('status')) { var binding = this.$bindings[message.name]; $(binding ? binding.el : null).trigger({ type: 'shiny:' + message.status }); } }); addMessageHandler('reload', function(message) { window.location.reload(); }); // Progress reporting ==================================================== var progressHandlers = { // Progress for a particular object binding: function(message) { var key = message.id; var binding = this.$bindings[key]; if (binding && binding.showProgress) { binding.showProgress(true); } }, // Open a page-level progress bar open: function(message) { // Add progress container (for all progress items) if not already present var $container = $('.shiny-progress-container'); if ($container.length === 0) { $container = $('
'); $('body').append($container); } // Add div for just this progress ID var depth = $('.shiny-progress.open').length; var $progress = $(progressHandlers.progressHTML); $progress.attr('id', message.id); $container.append($progress); // Stack bars var $progressBar = $progress.find('.progress'); $progressBar.css('top', depth * $progressBar.height() + 'px'); // Stack text objects var $progressText = $progress.find('.progress-text'); $progressText.css('top', 3 * $progressBar.height() + depth * $progressText.outerHeight() + 'px'); $progress.hide(); }, // Update page-level progress bar update: function(message) { var $progress = $('#' + message.id + '.shiny-progress'); if (typeof(message.message) !== 'undefined') { $progress.find('.progress-message').text(message.message); } if (typeof(message.detail) !== 'undefined') { $progress.find('.progress-detail').text(message.detail); } if (typeof(message.value) !== 'undefined') { if (message.value !== null) { $progress.find('.progress').show(); $progress.find('.bar').width((message.value*100) + '%'); } else { $progress.find('.progress').hide(); } } $progress.fadeIn(); }, // Close page-level progress bar close: function(message) { var $progress = $('#' + message.id + '.shiny-progress'); $progress.removeClass('open'); $progress.fadeOut({ complete: function() { $progress.remove(); // If this was the last shiny-progress, remove container if ($('.shiny-progress').length === 0) $('.shiny-progress-container').remove(); } }); }, // The 'bar' class is needed for backward compatibility with Bootstrap 2. progressHTML: '
' + '
' + '
' + 'message' + '' + '
' + '
' }; exports.progressHandlers = progressHandlers; }).call(ShinyApp.prototype);