Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 1 | module.exports = Playground; |
| 2 | |
| 3 | var _ = require('lodash'); |
| 4 | var http = require('http'); |
| 5 | var moment = require('moment'); |
| 6 | var path = require('path'); |
| 7 | var superagent = require('superagent'); |
| 8 | var url = require('url'); |
| 9 | |
| 10 | var m = require('mercury'); |
| 11 | var h = m.h; |
| 12 | |
| 13 | var Editor = require('./widgets/editor'); |
| 14 | var Spinner = require('./widgets/spinner'); |
| 15 | |
| 16 | // Timeout for save and load requests, in milliseconds. |
| 17 | var storageRequestTimeout = 1000; |
| 18 | |
| 19 | // Shows each file in a tab. |
| 20 | // * el: The DOM element to mount on. |
| 21 | // * name: Name of this playground instance, used in debug messages. |
| 22 | // * bundle: The default bundle formatted as {files:[{name, body}]}, as written |
| 23 | // by pgbundle, to use if id is unspecified or if load fails. |
| 24 | function Playground(el, name, bundle) { |
| 25 | this.name_ = name; |
| 26 | this.defaultBundle_ = bundle; |
| 27 | this.files_ = []; |
| 28 | this.editors_ = []; |
| 29 | this.editorSpinner_ = new Spinner(); |
| 30 | // scrollState_ changes should not trigger render_, thus are not monitored |
| 31 | // by mercury. |
| 32 | this.scrollState_ = {bottom: true}; |
| 33 | // Mercury framework state. Changes to state trigger virtual DOM render. |
| 34 | var state = m.state({ |
| 35 | notification: m.value({}), |
| 36 | // False on page load (no bundle), empty if default bundle is loaded. |
| 37 | bundleId: m.value(false), |
| 38 | // Incrementing counter on each bundle reload to force render. |
| 39 | bundleVersion: m.value(0), |
| 40 | activeTab: m.value(0), |
| 41 | idToLoad: m.value(''), |
| 42 | // Save or load request in progress. |
| 43 | requestActive: m.value(false), |
| 44 | nextRunId: m.value(0), |
| 45 | running: m.value(false), |
| 46 | hasRun: m.value(false), |
| 47 | consoleEvents: m.value([]), |
| 48 | // Mercury framework channels. References to event handlers callable from |
| 49 | // rendering code. |
| 50 | channels: { |
| 51 | switchTab: this.switchTab.bind(this), |
| 52 | setIdToLoad: this.setIdToLoad.bind(this), |
| 53 | // For testing. Load in production can be done purely using links. |
| 54 | // Reload is not necessary before adding history. |
| 55 | load: this.load.bind(this), |
| 56 | save: this.save.bind(this), |
| 57 | run: this.run.bind(this), |
| 58 | stop: this.stop.bind(this), |
| 59 | reset: this.reset.bind(this) |
| 60 | } |
| 61 | }); |
| 62 | m.app(el, state, this.render_.bind(this)); |
| 63 | // When page is first loaded, load bundle in url. |
| 64 | this.loadUrl_(state, window.location.href); |
| 65 | // When user goes forward/back, load bundle in url. |
| 66 | var that = this; |
| 67 | window.addEventListener('popstate', function(ev) { |
| 68 | console.log('window.onpopstate', ev); |
| 69 | that.loadUrl_(state, window.location.href); |
| 70 | }); |
| 71 | // Enable ev-scroll listening. |
| 72 | m.Delegator().listenTo('scroll'); |
| 73 | } |
| 74 | |
| 75 | // Attempts to load the bundle with id specified in the url, or the default |
| 76 | // bundle if id is not specified. |
| 77 | Playground.prototype.loadUrl_ = function(state, pgurl) { |
| 78 | this.setNotification_(state); |
| 79 | this.url_ = url.parse(pgurl, true); |
| 80 | // Deleted to make url.format() use this.url_.query. |
| 81 | delete this.url_.search; |
| 82 | var bId = this.url_.query.id || ''; |
| 83 | // Filled into idToLoad to allow retrying using Load button. |
| 84 | state.idToLoad.set(bId); |
| 85 | if (bId) { |
| 86 | console.log('Loading bundle', bId, 'from URL'); |
| 87 | process.nextTick(this.load.bind(this, state, {id: bId})); |
| 88 | } else { |
| 89 | console.log('Loading default bundle'); |
| 90 | process.nextTick( |
| 91 | this.setBundle_.bind(this, state, this.defaultBundle_, '')); |
| 92 | } |
| 93 | }; |
| 94 | |
| 95 | // Builds bundle object from editor contents. |
| 96 | Playground.prototype.getBundle_ = function() { |
| 97 | var editors = this.editors_; |
| 98 | return { |
| 99 | files: _.map(this.files_, function(file, i) { |
| 100 | var editor = editors[i]; |
| 101 | return { |
| 102 | name: file.name, |
| 103 | body: editor.getText() |
| 104 | }; |
| 105 | }) |
| 106 | }; |
| 107 | }; |
| 108 | |
| 109 | // Loads bundle object into editor contents, updates url. |
| 110 | Playground.prototype.setBundle_ = function(state, bundle, id) { |
| 111 | this.files_ = _.map(bundle.files, function(file) { |
| 112 | return _.assign({}, file, { |
| 113 | basename: path.basename(file.name), |
| 114 | type: path.extname(file.name).substr(1) |
| 115 | }); |
| 116 | }); |
| 117 | this.editors_ = _.map(this.files_, function(file) { |
| 118 | return new Editor(file.type, file.body); |
| 119 | }); |
| 120 | state.activeTab.set(0); |
| 121 | this.resetState_(state); |
| 122 | state.bundleId.set(id); |
| 123 | // Increment counter in state to force render. |
| 124 | state.bundleVersion.set((state.bundleVersion() + 1) & 0x7fffffff); |
| 125 | this.setUrlForId_(id); |
| 126 | }; |
| 127 | |
| 128 | // Updates url with new id if different from the current one. |
| 129 | Playground.prototype.setUrlForId_ = function(id) { |
| 130 | if (!id && !this.url_.query.id || id === this.url_.query.id) { |
| 131 | return; |
| 132 | } |
| 133 | if (!id) { |
| 134 | delete this.url_.query.id; |
| 135 | } else { |
| 136 | this.url_.query.id = id; |
| 137 | } |
| 138 | window.history.pushState(null, '', url.format(this.url_)); |
| 139 | }; |
| 140 | |
| 141 | // Determines base url for backend calls. |
| 142 | Playground.prototype.getBackendUrl_ = function() { |
| 143 | var pgaddr = this.url_.query.pgaddr; |
| 144 | if (pgaddr) { |
| 145 | console.log('Using pgaddr', pgaddr); |
| 146 | } else { |
Ivan Pilat | 49c7cc0 | 2015-02-12 16:16:26 -0800 | [diff] [blame] | 147 | // TODO(ivanpi): Change to detect staging/production. Restrict pgaddr. |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 148 | pgaddr = 'https://staging.v.io/playground'; |
| 149 | } |
| 150 | return pgaddr; |
| 151 | }; |
| 152 | |
| 153 | // Shows notification, green if success is set, red otherwise. |
| 154 | // Call with undefined/blank msg to clear notification. |
| 155 | Playground.prototype.setNotification_ = function(state, msg, success) { |
| 156 | if (!msg) { |
| 157 | state.notification.set({}); |
| 158 | } else { |
| 159 | state.notification.set({message: msg, ok: success}); |
| 160 | // TODO(ivanpi): Expire message. |
| 161 | } |
| 162 | }; |
| 163 | |
| 164 | // Renders button with provided label and target. |
| 165 | // If disable is true or a request is active, the button is disabled. |
| 166 | Playground.prototype.button_ = function(state, label, target, disable) { |
| 167 | if (disable || state.requestActive) { |
| 168 | return h('button.btn', { |
| 169 | disabled: true |
| 170 | }, label); |
| 171 | } else { |
| 172 | return h('button.btn', { |
| 173 | 'ev-click': target |
| 174 | }, label); |
| 175 | } |
| 176 | }; |
| 177 | |
| 178 | Playground.prototype.renderLoadBar_ = function(state) { |
| 179 | var idToLoad = h('input.bundleid', { |
| 180 | type: 'text', |
| 181 | name: 'idToLoad', |
| 182 | size: 64, |
| 183 | maxLength: 64, |
| 184 | value: state.idToLoad, |
| 185 | 'ev-input': m.sendChange(state.channels.setIdToLoad) |
| 186 | }); |
| 187 | var loadBtn = this.button_(state, 'Load', |
| 188 | m.sendClick(state.channels.load, {id: state.idToLoad}), |
| 189 | state.running); |
| 190 | |
| 191 | return h('div.widget-bar', [ |
| 192 | h('span', [idToLoad]), |
| 193 | h('span.btns', [loadBtn]) |
| 194 | ]); |
| 195 | }; |
| 196 | |
| 197 | Playground.prototype.renderResetBar_ = function(state) { |
| 198 | var idShow = h('span.bundleid', |
| 199 | state.bundleId || (state.bundleId === '' ? '<default>' : '<none>')); |
| 200 | var link = h('a', { |
| 201 | href: window.location.href |
| 202 | }, 'link'); |
| 203 | var notif = h('span.notif.' + (state.notification.ok ? 'success' : 'error'), |
| 204 | state.notification.message || ''); |
| 205 | |
| 206 | var resetBtn = this.button_(state, 'Reset', |
| 207 | m.sendClick(state.channels.reset), |
| 208 | state.bundleId === false); |
| 209 | var reloadBtn = this.button_(state, 'Reload', |
| 210 | m.sendClick(state.channels.load, {id: state.bundleId}), |
| 211 | state.running || !state.bundleId); |
| 212 | |
| 213 | return h('div.widget-bar', [ |
| 214 | h('span', [idShow, ' ', link, ' ', notif]), |
| 215 | h('span.btns', [resetBtn, reloadBtn]) |
| 216 | ]); |
| 217 | }; |
| 218 | |
| 219 | Playground.prototype.renderTabBar_ = function(state) { |
| 220 | var tabs = _.map(this.files_, function(file, i) { |
| 221 | var selector = 'div.tab'; |
| 222 | if (i === state.activeTab) { |
| 223 | selector += '.active'; |
| 224 | } |
| 225 | return h(selector, { |
| 226 | 'ev-click': m.sendClick(state.channels.switchTab, {index: i}) |
| 227 | }, file.basename); |
| 228 | }); |
| 229 | |
| 230 | var runStopBtn = state.running ? |
| 231 | this.button_(state, 'Stop', m.sendClick(state.channels.stop)) : |
| 232 | this.button_(state, 'Run', m.sendClick(state.channels.run), |
| 233 | state.bundleId === false); |
| 234 | var saveBtn = this.button_(state, 'Save', |
| 235 | m.sendClick(state.channels.save), |
| 236 | state.running || (state.bundleId === false)); |
| 237 | |
| 238 | return h('div.widget-bar', [ |
| 239 | h('span', tabs), |
| 240 | h('span.btns', [runStopBtn, saveBtn]) |
| 241 | ]); |
| 242 | }; |
| 243 | |
| 244 | Playground.prototype.renderEditors_ = function(state) { |
| 245 | var editors = _.map(this.editors_, function(editor, i) { |
| 246 | var properties = {}; |
| 247 | if (i !== state.activeTab) { |
| 248 | // Use "visibility: hidden" rather than "display: none" because the latter |
| 249 | // causes the editor to initialize lazily and thus flicker when it's first |
| 250 | // opened. |
| 251 | properties['style'] = {visibility: 'hidden'}; |
| 252 | } |
| 253 | return h('div.editor', properties, editor); |
| 254 | }); |
| 255 | |
| 256 | if (state.requestActive) { |
| 257 | editors.push(this.editorSpinner_); |
| 258 | } |
| 259 | |
| 260 | return h('div.editors', editors); |
| 261 | }; |
| 262 | |
| 263 | Playground.prototype.renderConsoleEvent_ = function(event) { |
| 264 | var children = []; |
| 265 | if (event.Timestamp) { |
| 266 | // Convert UTC to local time. |
| 267 | var t = moment(event.Timestamp / 1e6); |
| 268 | children.push(h('span.timestamp', t.format('H:mm:ss.SSS') + ' ')); |
| 269 | } |
| 270 | if (event.File) { |
| 271 | children.push(h('span.filename', path.basename(event.File) + ': ')); |
| 272 | } |
| 273 | // A single trailing newline is always ignored. |
| 274 | // Ignoring the last character, check if there are any newlines in message. |
| 275 | if (event.Message.slice(0, -1).indexOf('\n') !== -1) { |
| 276 | // Multiline messages are marked with U+23CE and started in a new line. |
| 277 | children.push('\u23ce'/* U+23CE RETURN SYMBOL */, h('br')); |
| 278 | } |
| 279 | children.push(h('span.message.' + (event.Stream || 'unknown'), |
| 280 | event.Message)); |
| 281 | return h('div', children); |
| 282 | }; |
| 283 | |
| 284 | // ScrollHandle provides a hook to keep the console scrolled to the bottom |
| 285 | // unless the user has scrolled up, and the update method to detect the |
| 286 | // user scrolling up. |
| 287 | function ScrollHandle(scrollState) { |
| 288 | this.scrollState_ = scrollState; |
Ivan Pilat | 19579cf | 2015-02-17 14:24:28 -0800 | [diff] [blame] | 289 | this.enableScrollUpdates_ = false; |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 290 | } |
| 291 | |
| 292 | ScrollHandle.prototype.hook = function(elem, propname) { |
Ivan Pilat | 19579cf | 2015-02-17 14:24:28 -0800 | [diff] [blame] | 293 | var that = this; |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 294 | process.nextTick(function() { |
Ivan Pilat | 19579cf | 2015-02-17 14:24:28 -0800 | [diff] [blame] | 295 | if (that.scrollState_.bottom) { |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 296 | elem.scrollTop = elem.scrollHeight - elem.clientHeight; |
| 297 | } |
Ivan Pilat | 19579cf | 2015-02-17 14:24:28 -0800 | [diff] [blame] | 298 | that.enableScrollUpdates_ = true; |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 299 | }); |
| 300 | }; |
| 301 | |
| 302 | ScrollHandle.prototype.update = function(ev) { |
Ivan Pilat | 19579cf | 2015-02-17 14:24:28 -0800 | [diff] [blame] | 303 | var el = ev.currentTarget; |
| 304 | if (this.enableScrollUpdates_) { |
| 305 | // scrollHeight and clientHeight are rounded to an integer, so we need to |
| 306 | // compare fuzzily. |
| 307 | this.scrollState_.bottom = |
| 308 | Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) <= 2.01; |
| 309 | } |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 310 | }; |
| 311 | |
| 312 | Playground.prototype.renderConsole_ = function(state) { |
| 313 | if (state.hasRun) { |
| 314 | var scrollHandle = new ScrollHandle(this.scrollState_); |
| 315 | return h('div.console.open', { |
| 316 | 'ev-scroll': scrollHandle.update.bind(scrollHandle), |
| 317 | 'scrollhook': scrollHandle |
| 318 | }, [ |
| 319 | h('div.text', _.map(state.consoleEvents, this.renderConsoleEvent_)) |
| 320 | ]); |
| 321 | } |
| 322 | return h('div.console'); |
| 323 | }; |
| 324 | |
| 325 | Playground.prototype.render_ = function(state) { |
| 326 | return h('div.pg', [ |
| 327 | this.renderLoadBar_(state), |
| 328 | this.renderResetBar_(state), |
| 329 | this.renderTabBar_(state), |
| 330 | this.renderEditors_(state), |
| 331 | this.renderConsole_(state) |
| 332 | ]); |
| 333 | }; |
| 334 | |
| 335 | // Switches active tab to data.index. |
| 336 | Playground.prototype.switchTab = function(state, data) { |
| 337 | this.setNotification_(state); |
| 338 | state.activeTab.set(data.index); |
| 339 | }; |
| 340 | |
| 341 | // Reads the idToLoad text box into state. |
| 342 | Playground.prototype.setIdToLoad = function(state, formdata) { |
| 343 | this.setNotification_(state); |
| 344 | state.idToLoad.set(formdata.idToLoad); |
| 345 | }; |
| 346 | |
| 347 | Playground.prototype.showMessage_ = function(state, prefix, msg, ok) { |
| 348 | var fullMsg = prefix + ': ' + msg; |
| 349 | if (ok) { |
| 350 | console.log(fullMsg); |
| 351 | } else { |
| 352 | console.error(fullMsg); |
| 353 | } |
| 354 | this.setNotification_(state, fullMsg, ok); |
| 355 | }; |
| 356 | |
| 357 | // Returns callback to be used for save and load requests. Callback loads the |
| 358 | // bundle returned from the server and updates bundleId and url. |
| 359 | Playground.prototype.saveLoadCallback_ = function(state, operation) { |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 360 | var that = this; |
| 361 | return function(rerr, res) { |
| 362 | state.requestActive.set(false); |
| 363 | var processResponse = function() { |
| 364 | if (rerr) { |
| 365 | if (rerr.timeout) { |
Ivan Pilat | 49c7cc0 | 2015-02-12 16:16:26 -0800 | [diff] [blame] | 366 | return 'request timed out'; |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 367 | } |
Ivan Pilat | 49c7cc0 | 2015-02-12 16:16:26 -0800 | [diff] [blame] | 368 | return 'error connecting to server: ' + rerr; |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 369 | } |
| 370 | if (res.body && res.body.Error) { |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 371 | // TODO(ivanpi): Special handling of 404? Retry on other errors? |
Ivan Pilat | 49c7cc0 | 2015-02-12 16:16:26 -0800 | [diff] [blame] | 372 | return 'error ' + res.status + ': ' + res.body.Error; |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 373 | } |
| 374 | if (res.error) { |
Ivan Pilat | 49c7cc0 | 2015-02-12 16:16:26 -0800 | [diff] [blame] | 375 | return 'error ' + res.status + ': unknown'; |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 376 | } |
| 377 | if (!res.body.Link || !res.body.Data) { |
Ivan Pilat | 49c7cc0 | 2015-02-12 16:16:26 -0800 | [diff] [blame] | 378 | return 'invalid response format'; |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 379 | } |
| 380 | var bundle; |
| 381 | try { |
| 382 | bundle = JSON.parse(res.body.Data); |
| 383 | } catch (jerr) { |
Ivan Pilat | 49c7cc0 | 2015-02-12 16:16:26 -0800 | [diff] [blame] | 384 | return 'error parsing Data: ' + res.body.Data + '\n' + jerr.message; |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 385 | } |
| 386 | // Opens bundle in editors, updates bundleId and url. |
| 387 | that.setBundle_(state, bundle, res.body.Link); |
Ivan Pilat | 49c7cc0 | 2015-02-12 16:16:26 -0800 | [diff] [blame] | 388 | return null; |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 389 | }; |
Ivan Pilat | 49c7cc0 | 2015-02-12 16:16:26 -0800 | [diff] [blame] | 390 | var errm = processResponse(); |
| 391 | if (!errm) { |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 392 | state.idToLoad.set(''); |
Ivan Pilat | 49c7cc0 | 2015-02-12 16:16:26 -0800 | [diff] [blame] | 393 | that.showMessage_(state, operation, 'success', true); |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 394 | } else { |
| 395 | // Load/save failed. |
| 396 | if (state.bundleId() === false) { |
| 397 | // If no bundle was loaded, load default. |
| 398 | that.setBundle_(state, that.defaultBundle_, ''); |
| 399 | } else { |
| 400 | // Otherwise, reset url to previously loaded bundle. |
| 401 | that.setUrlForId_(state.bundleId()); |
| 402 | } |
Ivan Pilat | 49c7cc0 | 2015-02-12 16:16:26 -0800 | [diff] [blame] | 403 | that.showMessage_(state, operation, errm); |
Ivan Pilat | a244086 | 2015-02-09 19:22:58 -0800 | [diff] [blame] | 404 | } |
| 405 | }; |
| 406 | }; |
| 407 | |
| 408 | // Loads bundle for data.id. |
| 409 | Playground.prototype.load = function(state, data) { |
| 410 | this.setNotification_(state); |
| 411 | if (!data.id) { |
| 412 | this.showMessage_(state, 'load', 'cannot load blank id'); |
| 413 | return; |
| 414 | } |
| 415 | superagent |
| 416 | .get(this.getBackendUrl_() + '/load?id=' + encodeURIComponent(data.id)) |
| 417 | .accept('json') |
| 418 | .timeout(storageRequestTimeout) |
| 419 | .end(this.saveLoadCallback_(state, 'load ' + data.id)); |
| 420 | state.requestActive.set(true); |
| 421 | }; |
| 422 | |
| 423 | // Saves bundle and updates bundleId with the received id. |
| 424 | Playground.prototype.save = function(state) { |
| 425 | this.setNotification_(state); |
| 426 | superagent |
| 427 | .post(this.getBackendUrl_() + '/save') |
| 428 | .type('json') |
| 429 | .accept('json') |
| 430 | .timeout(storageRequestTimeout) |
| 431 | .send(this.getBundle_()) |
| 432 | .end(this.saveLoadCallback_(state, 'save')); |
| 433 | state.requestActive.set(true); |
| 434 | }; |
| 435 | |
| 436 | // Sends the files to the compile backend, streaming the response into the |
| 437 | // console. |
| 438 | Playground.prototype.run = function(state) { |
| 439 | if (state.running()) { |
| 440 | console.log('Already running', this.name_); |
| 441 | return; |
| 442 | } |
| 443 | var runId = state.nextRunId(); |
| 444 | |
| 445 | this.setNotification_(state); |
| 446 | state.running.set(true); |
| 447 | state.hasRun.set(true); |
| 448 | state.consoleEvents.set([{Message: 'Running...'}]); |
| 449 | this.scrollState_.bottom = true; |
| 450 | |
| 451 | var compileUrl = this.getBackendUrl_() + '/compile'; |
| 452 | if (this.url_.query.debug === '1') { |
| 453 | compileUrl += '?debug=1'; |
| 454 | } |
| 455 | |
| 456 | var reqData = this.getBundle_(); |
| 457 | |
| 458 | // TODO(sadovsky): To deal with cached responses, shift timestamps (based on |
| 459 | // current time) and introduce a fake delay. Also, switch to streaming |
| 460 | // messages, for usability. |
| 461 | var that = this; |
| 462 | |
| 463 | // If the user stops the current run or resets the playground, functions |
| 464 | // wrapped with ifRunActive become no-ops. |
| 465 | var ifRunActive = function(cb) { |
| 466 | return function() { |
| 467 | if (runId === state.nextRunId()) { |
| 468 | cb.apply(this, arguments); |
| 469 | } |
| 470 | }; |
| 471 | }; |
| 472 | |
| 473 | var appendToConsole = function(events) { |
| 474 | state.consoleEvents.set(state.consoleEvents().concat(events)); |
| 475 | }; |
| 476 | var makeEvent = function(stream, message) { |
| 477 | return {Stream: stream, Message: message}; |
| 478 | }; |
| 479 | |
| 480 | var urlp = url.parse(compileUrl); |
| 481 | |
| 482 | var options = { |
| 483 | method: 'POST', |
| 484 | protocol: urlp.protocol, |
| 485 | hostname: urlp.hostname, |
| 486 | port: urlp.port || (urlp.protocol === 'https:' ? '443' : '80'), |
| 487 | path: urlp.path, |
| 488 | // TODO(ivanpi): Change once deployed. |
| 489 | withCredentials: false, |
| 490 | headers: { |
| 491 | 'accept': 'application/json', |
| 492 | 'content-type': 'application/json' |
| 493 | } |
| 494 | }; |
| 495 | |
| 496 | var req = http.request(options); |
| 497 | |
| 498 | var watchdog = null; |
| 499 | // The heartbeat function clears the existing timeout (if any) and, if the run |
| 500 | // is still active, starts a new timeout. |
| 501 | var heartbeat = function() { |
| 502 | if (watchdog !== null) { |
| 503 | clearTimeout(watchdog); |
| 504 | } |
| 505 | watchdog = null; |
| 506 | ifRunActive(function() { |
| 507 | // TODO(ivanpi): Reduce timeout duration when server heartbeat is added. |
| 508 | watchdog = setTimeout(function() { |
| 509 | process.nextTick(ifRunActive(function() { |
| 510 | req.destroy(); |
| 511 | appendToConsole(makeEvent('syserr', 'Server response timed out.')); |
| 512 | })); |
| 513 | }, 10500); |
| 514 | })(); |
| 515 | }; |
| 516 | |
| 517 | var endRunIfActive = ifRunActive(function() { |
| 518 | that.stop(state); |
| 519 | // Cleanup watchdog timer. |
| 520 | heartbeat(); |
| 521 | }); |
| 522 | |
| 523 | // error and close callbacks call endRunIfActive in the next tick to ensure |
| 524 | // that if both events are triggered, both are executed before the run is |
| 525 | // ended by either. |
| 526 | req.on('error', ifRunActive(function(err) { |
| 527 | console.error('Connection error: ' + err.message + '\n' + err.stack); |
| 528 | appendToConsole(makeEvent('syserr', 'Error connecting to server.')); |
| 529 | process.nextTick(endRunIfActive); |
| 530 | })); |
| 531 | |
| 532 | // Holds partial prefix of next response line. |
| 533 | var partialLine = ''; |
| 534 | |
| 535 | req.on('response', ifRunActive(function(res) { |
| 536 | heartbeat(); |
| 537 | if (res.statusCode !== 0 && res.statusCode !== 200) { |
| 538 | appendToConsole(makeEvent('syserr', 'HTTP status ' + res.statusCode)); |
| 539 | } |
| 540 | res.on('data', ifRunActive(function(chunk) { |
| 541 | heartbeat(); |
| 542 | // Each complete line is one JSON Event. |
| 543 | var eventsJson = (partialLine + chunk).split('\n'); |
| 544 | partialLine = eventsJson.pop(); |
| 545 | var events = []; |
| 546 | _.forEach(eventsJson, function(line) { |
| 547 | // Ignore empty lines. |
| 548 | line = line.trim(); |
| 549 | if (line) { |
| 550 | var ev; |
| 551 | try { |
| 552 | ev = JSON.parse(line); |
| 553 | } catch (err) { |
| 554 | console.error('Error parsing line: ' + line + '\n' + err.message); |
| 555 | events.push(makeEvent('syserr', 'Error parsing server response.')); |
| 556 | endRunIfActive(); |
| 557 | return false; |
| 558 | } |
| 559 | events.push(ev); |
| 560 | } |
| 561 | }); |
| 562 | appendToConsole(events); |
| 563 | })); |
| 564 | })); |
| 565 | |
| 566 | req.on('close', ifRunActive(function() { |
| 567 | // Sanity check: partialLine should be empty when connection is closed. |
| 568 | partialLine = partialLine.trim(); |
| 569 | if (partialLine) { |
| 570 | console.error('Connection closed without newline after: ' + partialLine); |
| 571 | appendToConsole(makeEvent('syserr', 'Error parsing server response.')); |
| 572 | } |
| 573 | process.nextTick(endRunIfActive); |
| 574 | })); |
| 575 | |
| 576 | req.write(JSON.stringify(reqData)); |
| 577 | req.end(); |
| 578 | |
| 579 | // Start watchdog. |
| 580 | heartbeat(); |
| 581 | }; |
| 582 | |
| 583 | // Clears the console and resets all editors to their original contents. |
| 584 | Playground.prototype.reset = function(state) { |
| 585 | this.resetState_(state); |
| 586 | _.forEach(this.editors_, function(editor) { |
| 587 | editor.reset(); |
| 588 | }); |
| 589 | this.setUrlForId_(state.bundleId()); |
| 590 | }; |
| 591 | |
| 592 | Playground.prototype.resetState_ = function(state) { |
| 593 | state.consoleEvents.set([]); |
| 594 | this.scrollState_.bottom = true; |
| 595 | this.stop(state); |
| 596 | state.hasRun.set(false); |
| 597 | }; |
| 598 | |
| 599 | // Stops bundle execution. |
| 600 | Playground.prototype.stop = function(state) { |
| 601 | this.setNotification_(state); |
| 602 | state.nextRunId.set((state.nextRunId() + 1) & 0x7fffffff); |
| 603 | state.running.set(false); |
| 604 | }; |