blob: 9e8ece07de1f140d081d751663b3187d6d639143 [file] [log] [blame]
Ivan Pilata2440862015-02-09 19:22:58 -08001module.exports = Playground;
2
3var _ = require('lodash');
4var http = require('http');
5var moment = require('moment');
6var path = require('path');
7var superagent = require('superagent');
8var url = require('url');
9
10var m = require('mercury');
11var h = m.h;
12
13var Editor = require('./widgets/editor');
14var Spinner = require('./widgets/spinner');
15
16// Timeout for save and load requests, in milliseconds.
17var 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.
24function 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.
77Playground.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.
96Playground.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.
110Playground.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.
129Playground.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.
142Playground.prototype.getBackendUrl_ = function() {
143 var pgaddr = this.url_.query.pgaddr;
144 if (pgaddr) {
145 console.log('Using pgaddr', pgaddr);
146 } else {
Ivan Pilat49c7cc02015-02-12 16:16:26 -0800147 // TODO(ivanpi): Change to detect staging/production. Restrict pgaddr.
Ivan Pilata2440862015-02-09 19:22:58 -0800148 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.
155Playground.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.
166Playground.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
178Playground.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
197Playground.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
219Playground.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
244Playground.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
263Playground.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.
287function ScrollHandle(scrollState) {
288 this.scrollState_ = scrollState;
Ivan Pilat19579cf2015-02-17 14:24:28 -0800289 this.enableScrollUpdates_ = false;
Ivan Pilata2440862015-02-09 19:22:58 -0800290}
291
292ScrollHandle.prototype.hook = function(elem, propname) {
Ivan Pilat19579cf2015-02-17 14:24:28 -0800293 var that = this;
Ivan Pilata2440862015-02-09 19:22:58 -0800294 process.nextTick(function() {
Ivan Pilat19579cf2015-02-17 14:24:28 -0800295 if (that.scrollState_.bottom) {
Ivan Pilata2440862015-02-09 19:22:58 -0800296 elem.scrollTop = elem.scrollHeight - elem.clientHeight;
297 }
Ivan Pilat19579cf2015-02-17 14:24:28 -0800298 that.enableScrollUpdates_ = true;
Ivan Pilata2440862015-02-09 19:22:58 -0800299 });
300};
301
302ScrollHandle.prototype.update = function(ev) {
Ivan Pilat19579cf2015-02-17 14:24:28 -0800303 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 Pilata2440862015-02-09 19:22:58 -0800310};
311
312Playground.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
325Playground.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.
336Playground.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.
342Playground.prototype.setIdToLoad = function(state, formdata) {
343 this.setNotification_(state);
344 state.idToLoad.set(formdata.idToLoad);
345};
346
347Playground.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.
359Playground.prototype.saveLoadCallback_ = function(state, operation) {
Ivan Pilata2440862015-02-09 19:22:58 -0800360 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 Pilat49c7cc02015-02-12 16:16:26 -0800366 return 'request timed out';
Ivan Pilata2440862015-02-09 19:22:58 -0800367 }
Ivan Pilat49c7cc02015-02-12 16:16:26 -0800368 return 'error connecting to server: ' + rerr;
Ivan Pilata2440862015-02-09 19:22:58 -0800369 }
370 if (res.body && res.body.Error) {
Ivan Pilata2440862015-02-09 19:22:58 -0800371 // TODO(ivanpi): Special handling of 404? Retry on other errors?
Ivan Pilat49c7cc02015-02-12 16:16:26 -0800372 return 'error ' + res.status + ': ' + res.body.Error;
Ivan Pilata2440862015-02-09 19:22:58 -0800373 }
374 if (res.error) {
Ivan Pilat49c7cc02015-02-12 16:16:26 -0800375 return 'error ' + res.status + ': unknown';
Ivan Pilata2440862015-02-09 19:22:58 -0800376 }
377 if (!res.body.Link || !res.body.Data) {
Ivan Pilat49c7cc02015-02-12 16:16:26 -0800378 return 'invalid response format';
Ivan Pilata2440862015-02-09 19:22:58 -0800379 }
380 var bundle;
381 try {
382 bundle = JSON.parse(res.body.Data);
383 } catch (jerr) {
Ivan Pilat49c7cc02015-02-12 16:16:26 -0800384 return 'error parsing Data: ' + res.body.Data + '\n' + jerr.message;
Ivan Pilata2440862015-02-09 19:22:58 -0800385 }
386 // Opens bundle in editors, updates bundleId and url.
387 that.setBundle_(state, bundle, res.body.Link);
Ivan Pilat49c7cc02015-02-12 16:16:26 -0800388 return null;
Ivan Pilata2440862015-02-09 19:22:58 -0800389 };
Ivan Pilat49c7cc02015-02-12 16:16:26 -0800390 var errm = processResponse();
391 if (!errm) {
Ivan Pilata2440862015-02-09 19:22:58 -0800392 state.idToLoad.set('');
Ivan Pilat49c7cc02015-02-12 16:16:26 -0800393 that.showMessage_(state, operation, 'success', true);
Ivan Pilata2440862015-02-09 19:22:58 -0800394 } 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 Pilat49c7cc02015-02-12 16:16:26 -0800403 that.showMessage_(state, operation, errm);
Ivan Pilata2440862015-02-09 19:22:58 -0800404 }
405 };
406};
407
408// Loads bundle for data.id.
409Playground.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.
424Playground.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.
438Playground.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.
584Playground.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
592Playground.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.
600Playground.prototype.stop = function(state) {
601 this.setNotification_(state);
602 state.nextRunId.set((state.nextRunId() + 1) & 0x7fffffff);
603 state.running.set(false);
604};