| // Copyright 2015 The Vanadium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| module.exports = Playground; |
| |
| var _ = require('lodash'); |
| var http = require('http'); |
| var moment = require('moment'); |
| var path = require('path'); |
| var superagent = require('superagent'); |
| var url = require('url'); |
| |
| var m = require('mercury'); |
| var h = m.h; |
| |
| var Editor = require('./widgets/editor'); |
| var Spinner = require('./widgets/spinner'); |
| |
| // Timeout for save and load requests, in milliseconds. |
| var storageRequestTimeout = 1000; |
| |
| // Shows each file in a tab. |
| // * el: The DOM element to mount on. |
| // * name: Name of this playground instance, used in debug messages. |
| // * bundle: The default bundle formatted as {files:[{name, body}]}, as written |
| // by pgbundle, to use if id is unspecified or if load fails. |
| function Playground(el, name, bundle) { |
| this.name_ = name; |
| this.defaultBundle_ = bundle; |
| this.files_ = []; |
| this.editors_ = []; |
| this.editorSpinner_ = new Spinner(); |
| // scrollState_ changes should not trigger render_, thus are not monitored |
| // by mercury. |
| this.scrollState_ = {bottom: true}; |
| // Mercury framework state. Changes to state trigger virtual DOM render. |
| var state = m.state({ |
| notification: m.value({}), |
| // False on page load (no bundle), empty if default bundle is loaded. |
| bundleId: m.value(false), |
| // Incrementing counter on each bundle reload to force render. |
| bundleVersion: m.value(0), |
| activeTab: m.value(0), |
| idToLoad: m.value(''), |
| // Save or load request in progress. |
| requestActive: m.value(false), |
| nextRunId: m.value(0), |
| running: m.value(false), |
| hasRun: m.value(false), |
| consoleEvents: m.value([]), |
| // Mercury framework channels. References to event handlers callable from |
| // rendering code. |
| channels: { |
| switchTab: this.switchTab.bind(this), |
| setIdToLoad: this.setIdToLoad.bind(this), |
| // For testing. Load in production can be done purely using links. |
| // Reload is not necessary before adding history. |
| load: this.load.bind(this), |
| save: this.save.bind(this), |
| run: this.run.bind(this), |
| stop: this.stop.bind(this), |
| reset: this.reset.bind(this) |
| } |
| }); |
| m.app(el, state, this.render_.bind(this)); |
| // When page is first loaded, load bundle in url. |
| this.loadUrl_(state, window.location.href); |
| // When user goes forward/back, load bundle in url. |
| var that = this; |
| window.addEventListener('popstate', function(ev) { |
| console.log('window.onpopstate', ev); |
| that.loadUrl_(state, window.location.href); |
| }); |
| // Enable ev-scroll listening. |
| m.Delegator().listenTo('scroll'); |
| } |
| |
| // Attempts to load the bundle with id specified in the url, or the default |
| // bundle if id is not specified. |
| Playground.prototype.loadUrl_ = function(state, pgurl) { |
| this.setNotification_(state); |
| this.url_ = url.parse(pgurl, true); |
| // Deleted to make url.format() use this.url_.query. |
| delete this.url_.search; |
| var bId = this.url_.query.id || ''; |
| // Filled into idToLoad to allow retrying using Load button. |
| state.idToLoad.set(bId); |
| if (bId) { |
| console.log('Loading bundle', bId, 'from URL'); |
| process.nextTick(this.load.bind(this, state, {id: bId})); |
| } else { |
| console.log('Loading default bundle'); |
| process.nextTick( |
| this.setBundle_.bind(this, state, this.defaultBundle_, '')); |
| } |
| }; |
| |
| // Builds bundle object from editor contents. |
| Playground.prototype.getBundle_ = function() { |
| var editors = this.editors_; |
| return { |
| files: _.map(this.files_, function(file, i) { |
| var editor = editors[i]; |
| return { |
| name: file.name, |
| body: editor.getText() |
| }; |
| }) |
| }; |
| }; |
| |
| // Loads bundle object into editor contents, updates url. |
| Playground.prototype.setBundle_ = function(state, bundle, id) { |
| this.files_ = _.map(bundle.files, function(file) { |
| return _.assign({}, file, { |
| basename: path.basename(file.name), |
| type: path.extname(file.name).substr(1) |
| }); |
| }); |
| this.editors_ = _.map(this.files_, function(file) { |
| return new Editor(file.type, file.body); |
| }); |
| state.activeTab.set(0); |
| this.resetState_(state); |
| state.bundleId.set(id); |
| // Increment counter in state to force render. |
| state.bundleVersion.set((state.bundleVersion() + 1) & 0x7fffffff); |
| this.setUrlForId_(id); |
| }; |
| |
| // Updates url with new id if different from the current one. |
| Playground.prototype.setUrlForId_ = function(id) { |
| if (!id && !this.url_.query.id || id === this.url_.query.id) { |
| return; |
| } |
| if (!id) { |
| delete this.url_.query.id; |
| } else { |
| this.url_.query.id = id; |
| } |
| window.history.pushState(null, '', url.format(this.url_)); |
| }; |
| |
| // Determines base url for backend calls. |
| Playground.prototype.getBackendUrl_ = function() { |
| var pgaddr = this.url_.query.pgaddr; |
| if (pgaddr) { |
| if (window.location.hostname === 'localhost') { |
| console.log('Using pgaddr', pgaddr); |
| return pgaddr; |
| } else { |
| console.error('pgaddr disabled on non-localhost clients'); |
| pgaddr = ''; |
| } |
| } |
| var origin = window.location.protocol + '//' + window.location.host; |
| var defaddr = origin + '/api'; |
| console.log('Using default backend', defaddr); |
| return defaddr; |
| }; |
| |
| // Shows notification, green if success is set, red otherwise. |
| // Call with undefined/blank msg to clear notification. |
| Playground.prototype.setNotification_ = function(state, msg, success) { |
| if (!msg) { |
| state.notification.set({}); |
| } else { |
| state.notification.set({message: msg, ok: success}); |
| // TODO(ivanpi): Expire message. |
| } |
| }; |
| |
| // Renders button with provided label and target. |
| // If disable is true or a request is active, the button is disabled. |
| Playground.prototype.button_ = function(state, label, target, disable) { |
| if (disable || state.requestActive) { |
| return h('button.btn', { |
| disabled: true |
| }, label); |
| } else { |
| return h('button.btn', { |
| 'ev-click': target |
| }, label); |
| } |
| }; |
| |
| Playground.prototype.renderLoadBar_ = function(state) { |
| var idToLoad = h('input.bundleid', { |
| type: 'text', |
| name: 'idToLoad', |
| size: 64, |
| maxLength: 64, |
| value: state.idToLoad, |
| 'ev-input': m.sendChange(state.channels.setIdToLoad) |
| }); |
| var loadBtn = this.button_(state, 'Load', |
| m.sendClick(state.channels.load, {id: state.idToLoad}), |
| state.running); |
| |
| return h('div.widget-bar', [ |
| h('span', [idToLoad]), |
| h('span.btns', [loadBtn]) |
| ]); |
| }; |
| |
| Playground.prototype.renderResetBar_ = function(state) { |
| var idShow = h('span.bundleid', |
| state.bundleId || (state.bundleId === '' ? '<default>' : '<none>')); |
| var link = h('a', { |
| href: window.location.href |
| }, 'link'); |
| var notif = h('span.notif.' + (state.notification.ok ? 'success' : 'error'), |
| state.notification.message || ''); |
| |
| var resetBtn = this.button_(state, 'Reset', |
| m.sendClick(state.channels.reset), |
| state.bundleId === false); |
| var reloadBtn = this.button_(state, 'Reload', |
| m.sendClick(state.channels.load, {id: state.bundleId}), |
| state.running || !state.bundleId); |
| |
| return h('div.widget-bar', [ |
| h('span', [idShow, ' ', link, ' ', notif]), |
| h('span.btns', [resetBtn, reloadBtn]) |
| ]); |
| }; |
| |
| Playground.prototype.renderTabBar_ = function(state) { |
| var tabs = _.map(this.files_, function(file, i) { |
| var selector = 'div.tab'; |
| if (i === state.activeTab) { |
| selector += '.active'; |
| } |
| return h(selector, { |
| 'ev-click': m.sendClick(state.channels.switchTab, {index: i}) |
| }, file.basename); |
| }); |
| |
| var runStopBtn = state.running ? |
| this.button_(state, 'Stop', m.sendClick(state.channels.stop)) : |
| this.button_(state, 'Run', m.sendClick(state.channels.run), |
| state.bundleId === false); |
| var saveBtn = this.button_(state, 'Save', |
| m.sendClick(state.channels.save), |
| state.running || (state.bundleId === false)); |
| |
| return h('div.widget-bar', [ |
| h('span', tabs), |
| h('span.btns', [runStopBtn, saveBtn]) |
| ]); |
| }; |
| |
| Playground.prototype.renderEditors_ = function(state) { |
| var editors = _.map(this.editors_, function(editor, i) { |
| var properties = {}; |
| if (i !== state.activeTab) { |
| // Use "visibility: hidden" rather than "display: none" because the latter |
| // causes the editor to initialize lazily and thus flicker when it's first |
| // opened. |
| properties['style'] = {visibility: 'hidden'}; |
| } |
| return h('div.editor', properties, editor); |
| }); |
| |
| if (state.requestActive) { |
| editors.push(this.editorSpinner_); |
| } |
| |
| return h('div.editors', editors); |
| }; |
| |
| Playground.prototype.renderConsoleEvent_ = function(event) { |
| var children = []; |
| if (event.Timestamp) { |
| // Convert UTC to local time. |
| var t = moment(event.Timestamp / 1e6); |
| children.push(h('span.timestamp', t.format('H:mm:ss.SSS') + ' ')); |
| } |
| if (event.File) { |
| children.push(h('span.filename', path.basename(event.File) + ': ')); |
| } |
| // A single trailing newline is always ignored. |
| // Ignoring the last character, check if there are any newlines in message. |
| if (event.Message.slice(0, -1).indexOf('\n') !== -1) { |
| // Multiline messages are marked with U+23CE and started in a new line. |
| children.push('\u23ce'/* U+23CE RETURN SYMBOL */, h('br')); |
| } |
| children.push(h('span.message.' + (event.Stream || 'unknown'), |
| event.Message)); |
| return h('div', children); |
| }; |
| |
| // ScrollHandle provides a hook to keep the console scrolled to the bottom |
| // unless the user has scrolled up, and the update method to detect the |
| // user scrolling up. |
| function ScrollHandle(scrollState) { |
| this.scrollState_ = scrollState; |
| this.enableScrollUpdates_ = false; |
| } |
| |
| ScrollHandle.prototype.hook = function(elem, propname) { |
| var that = this; |
| process.nextTick(function() { |
| if (that.scrollState_.bottom) { |
| elem.scrollTop = elem.scrollHeight - elem.clientHeight; |
| } |
| that.enableScrollUpdates_ = true; |
| }); |
| }; |
| |
| ScrollHandle.prototype.update = function(ev) { |
| var el = ev.currentTarget; |
| if (this.enableScrollUpdates_) { |
| // scrollHeight and clientHeight are rounded to an integer, so we need to |
| // compare fuzzily. |
| this.scrollState_.bottom = |
| Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) <= 2.01; |
| } |
| }; |
| |
| Playground.prototype.renderConsole_ = function(state) { |
| if (state.hasRun) { |
| var scrollHandle = new ScrollHandle(this.scrollState_); |
| return h('div.console.open', { |
| 'ev-scroll': scrollHandle.update.bind(scrollHandle), |
| 'scrollhook': scrollHandle |
| }, [ |
| h('div.text', _.map(state.consoleEvents, this.renderConsoleEvent_)) |
| ]); |
| } |
| return h('div.console'); |
| }; |
| |
| Playground.prototype.render_ = function(state) { |
| return h('div.pg', [ |
| this.renderLoadBar_(state), |
| this.renderResetBar_(state), |
| this.renderTabBar_(state), |
| this.renderEditors_(state), |
| this.renderConsole_(state) |
| ]); |
| }; |
| |
| // Switches active tab to data.index. |
| Playground.prototype.switchTab = function(state, data) { |
| this.setNotification_(state); |
| state.activeTab.set(data.index); |
| }; |
| |
| // Reads the idToLoad text box into state. |
| Playground.prototype.setIdToLoad = function(state, formdata) { |
| this.setNotification_(state); |
| state.idToLoad.set(formdata.idToLoad); |
| }; |
| |
| Playground.prototype.showMessage_ = function(state, prefix, msg, ok) { |
| var fullMsg = prefix + ': ' + msg; |
| if (ok) { |
| console.log(fullMsg); |
| } else { |
| console.error(fullMsg); |
| } |
| this.setNotification_(state, fullMsg, ok); |
| }; |
| |
| // Returns callback to be used for save and load requests. Callback loads the |
| // bundle returned from the server and updates bundleId and url. |
| Playground.prototype.saveLoadCallback_ = function(state, operation) { |
| var that = this; |
| return function(rerr, res) { |
| state.requestActive.set(false); |
| var processResponse = function() { |
| if (rerr) { |
| if (rerr.timeout) { |
| return 'request timed out'; |
| } |
| return 'error connecting to server: ' + rerr; |
| } |
| if (res.body && res.body.Error) { |
| // TODO(ivanpi): Special handling of 404? Retry on other errors? |
| return 'error ' + res.status + ': ' + res.body.Error; |
| } |
| if (res.error) { |
| return 'error ' + res.status + ': unknown'; |
| } |
| if (!res.body.Link || !res.body.Data) { |
| return 'invalid response format'; |
| } |
| var bundle; |
| try { |
| bundle = JSON.parse(res.body.Data); |
| } catch (jerr) { |
| return 'error parsing Data: ' + res.body.Data + '\n' + jerr.message; |
| } |
| // Opens bundle in editors, updates bundleId and url. |
| that.setBundle_(state, bundle, res.body.Link); |
| return null; |
| }; |
| var errm = processResponse(); |
| if (!errm) { |
| state.idToLoad.set(''); |
| that.showMessage_(state, operation, 'success', true); |
| } else { |
| // Load/save failed. |
| if (state.bundleId() === false) { |
| // If no bundle was loaded, load default. |
| that.setBundle_(state, that.defaultBundle_, ''); |
| } else { |
| // Otherwise, reset url to previously loaded bundle. |
| that.setUrlForId_(state.bundleId()); |
| } |
| that.showMessage_(state, operation, errm); |
| } |
| }; |
| }; |
| |
| // Loads bundle for data.id. |
| Playground.prototype.load = function(state, data) { |
| this.setNotification_(state); |
| if (!data.id) { |
| this.showMessage_(state, 'load', 'cannot load blank id'); |
| return; |
| } |
| superagent |
| .get(this.getBackendUrl_() + '/load?id=' + encodeURIComponent(data.id)) |
| .accept('json') |
| .timeout(storageRequestTimeout) |
| .end(this.saveLoadCallback_(state, 'load ' + data.id)); |
| state.requestActive.set(true); |
| }; |
| |
| // Saves bundle and updates bundleId with the received id. |
| Playground.prototype.save = function(state) { |
| this.setNotification_(state); |
| superagent |
| .post(this.getBackendUrl_() + '/save') |
| .type('json') |
| .accept('json') |
| .timeout(storageRequestTimeout) |
| .send(this.getBundle_()) |
| .end(this.saveLoadCallback_(state, 'save')); |
| state.requestActive.set(true); |
| }; |
| |
| // Sends the files to the compile backend, streaming the response into the |
| // console. |
| Playground.prototype.run = function(state) { |
| if (state.running()) { |
| console.log('Already running', this.name_); |
| return; |
| } |
| var runId = state.nextRunId(); |
| |
| this.setNotification_(state); |
| state.running.set(true); |
| state.hasRun.set(true); |
| state.consoleEvents.set([{Message: 'Running...'}]); |
| this.scrollState_.bottom = true; |
| |
| var compileUrl = this.getBackendUrl_() + '/compile'; |
| if (this.url_.query.debug === '1') { |
| compileUrl += '?debug=1'; |
| } |
| |
| var reqData = this.getBundle_(); |
| |
| // TODO(sadovsky): To deal with cached responses, shift timestamps (based on |
| // current time) and introduce a fake delay. Also, switch to streaming |
| // messages, for usability. |
| var that = this; |
| |
| // If the user stops the current run or resets the playground, functions |
| // wrapped with ifRunActive become no-ops. |
| var ifRunActive = function(cb) { |
| return function() { |
| if (runId === state.nextRunId()) { |
| cb.apply(this, arguments); |
| } |
| }; |
| }; |
| |
| var appendToConsole = function(events) { |
| state.consoleEvents.set(state.consoleEvents().concat(events)); |
| }; |
| var makeEvent = function(stream, message) { |
| return {Stream: stream, Message: message}; |
| }; |
| |
| var urlp = url.parse(compileUrl); |
| |
| var options = { |
| method: 'POST', |
| protocol: urlp.protocol, |
| hostname: urlp.hostname, |
| port: urlp.port || (urlp.protocol === 'https:' ? '443' : '80'), |
| path: urlp.path, |
| // TODO(ivanpi): Change once deployed. |
| withCredentials: false, |
| headers: { |
| 'accept': 'application/json', |
| 'content-type': 'application/json' |
| } |
| }; |
| |
| var req = http.request(options); |
| |
| var watchdog = null; |
| // The heartbeat function clears the existing timeout (if any) and, if the run |
| // is still active, starts a new timeout. |
| var heartbeat = function() { |
| if (watchdog !== null) { |
| clearTimeout(watchdog); |
| } |
| watchdog = null; |
| ifRunActive(function() { |
| // TODO(ivanpi): Reduce timeout duration when server heartbeat is added. |
| watchdog = setTimeout(function() { |
| process.nextTick(ifRunActive(function() { |
| req.destroy(); |
| appendToConsole(makeEvent('syserr', 'Server response timed out.')); |
| })); |
| }, 10500); |
| })(); |
| }; |
| |
| var endRunIfActive = ifRunActive(function() { |
| that.stop(state); |
| // Cleanup watchdog timer. |
| heartbeat(); |
| }); |
| |
| // error and close callbacks call endRunIfActive in the next tick to ensure |
| // that if both events are triggered, both are executed before the run is |
| // ended by either. |
| req.on('error', ifRunActive(function(err) { |
| console.error('Connection error: ' + err.message + '\n' + err.stack); |
| appendToConsole(makeEvent('syserr', 'Error connecting to server.')); |
| process.nextTick(endRunIfActive); |
| })); |
| |
| // Holds partial prefix of next response line. |
| var partialLine = ''; |
| |
| req.on('response', ifRunActive(function(res) { |
| heartbeat(); |
| if (res.statusCode !== 0 && res.statusCode !== 200) { |
| appendToConsole(makeEvent('syserr', 'HTTP status ' + res.statusCode)); |
| } |
| res.on('data', ifRunActive(function(chunk) { |
| heartbeat(); |
| // Each complete line is one JSON Event. |
| var eventsJson = (partialLine + chunk).split('\n'); |
| partialLine = eventsJson.pop(); |
| var events = []; |
| _.forEach(eventsJson, function(line) { |
| // Ignore empty lines. |
| line = line.trim(); |
| if (line) { |
| var ev; |
| try { |
| ev = JSON.parse(line); |
| } catch (err) { |
| console.error('Error parsing line: ' + line + '\n' + err.message); |
| events.push(makeEvent('syserr', 'Error parsing server response.')); |
| endRunIfActive(); |
| return false; |
| } |
| events.push(ev); |
| } |
| }); |
| appendToConsole(events); |
| })); |
| })); |
| |
| req.on('close', ifRunActive(function() { |
| // Sanity check: partialLine should be empty when connection is closed. |
| partialLine = partialLine.trim(); |
| if (partialLine) { |
| console.error('Connection closed without newline after: ' + partialLine); |
| appendToConsole(makeEvent('syserr', 'Error parsing server response.')); |
| } |
| process.nextTick(endRunIfActive); |
| })); |
| |
| req.write(JSON.stringify(reqData)); |
| req.end(); |
| |
| // Start watchdog. |
| heartbeat(); |
| }; |
| |
| // Clears the console and resets all editors to their original contents. |
| Playground.prototype.reset = function(state) { |
| this.resetState_(state); |
| _.forEach(this.editors_, function(editor) { |
| editor.reset(); |
| }); |
| this.setUrlForId_(state.bundleId()); |
| }; |
| |
| Playground.prototype.resetState_ = function(state) { |
| state.consoleEvents.set([]); |
| this.scrollState_.bottom = true; |
| this.stop(state); |
| state.hasRun.set(false); |
| }; |
| |
| // Stops bundle execution. |
| Playground.prototype.stop = function(state) { |
| this.setNotification_(state); |
| state.nextRunId.set((state.nextRunId() + 1) & 0x7fffffff); |
| state.running.set(false); |
| }; |