diff --git a/client/commonFramework/add-faux-stream.js b/client/commonFramework/add-faux-stream.js deleted file mode 100644 index 17a57e212c..0000000000 --- a/client/commonFramework/add-faux-stream.js +++ /dev/null @@ -1,24 +0,0 @@ -window.common = (function(global) { - const { - common = { init: [] } - } = global; - - - const faux$ = common.getScriptContent$('/js/faux.js').shareReplay(); - - common.hasJs = function hasJs(code = '') { - return code.match(/\<\s?script\s?\>/gi) && - code.match(/\<\s?\/\s?script\s?\>/gi); - }; - - common.addFaux$ = function addFaux$(code) { - // grab user javaScript - var scriptCode = code - .split(/\<\s?script\s?\>/gi)[1] - .split(/\<\s?\/\s?script\s?\>/gi)[0]; - - return faux$.map(faux => faux + scriptCode); - }; - - return common; -}(window)); diff --git a/client/commonFramework/add-loop-protect.js b/client/commonFramework/add-loop-protect.js new file mode 100644 index 0000000000..65aebd0ed4 --- /dev/null +++ b/client/commonFramework/add-loop-protect.js @@ -0,0 +1,17 @@ +window.common = (function(global) { + const { + loopProtect, + common = { init: [] } + } = global; + + loopProtect.hit = function hit(line) { + var err = `Error: Exiting potential infinite loop at line ${line}.`; + console.error(err); + }; + + common.addLoopProtect = function addLoopProtect(code = '') { + return loopProtect(code); + }; + + return common; +})(window); diff --git a/client/commonFramework/add-test-to-string.js b/client/commonFramework/add-test-to-string.js deleted file mode 100644 index 52e78a82b1..0000000000 --- a/client/commonFramework/add-test-to-string.js +++ /dev/null @@ -1,38 +0,0 @@ -window.common = (function({ common = { init: [] }}) { - - var BDDregex = new RegExp( - '(expect(\\s+)?\\(.*\\;)|' + - '(assert(\\s+)?\\(.*\\;)|' + - '(assert\\.\\w.*\\;)|' + - '(.*\\.should\\..*\\;)/' - ); - - common.addTestsToString = function({ code, tests = [], ...rest }) { - const userTests = []; - - code = tests.reduce((code, test) => code + test + '\n', code + '\n'); - - var counter = 0; - var match = BDDregex.exec(code); - - while (match) { - var replacement = '//' + counter + common.salt; - code = code.substring(0, match.index) + - replacement + - code.substring(match.index + match[0].length); - - userTests.push({ - err: null, - text: match[0], - line: counter - }); - - counter++; - match = BDDregex.exec(code); - } - - return { ...rest, code, userTests }; - }; - - return common; -}(window)); diff --git a/client/commonFramework/detect-loops-stream.js b/client/commonFramework/detect-loops-stream.js deleted file mode 100644 index e60c49eea9..0000000000 --- a/client/commonFramework/detect-loops-stream.js +++ /dev/null @@ -1,79 +0,0 @@ -window.common = (function(global) { - const { - jailed, - document: doc, - Rx: { Observable, Disposable }, - common = { init: [] } - } = global; - - if (!jailed) { - return (code, cb) => cb(new Error('Could not load jailed plugin')); - } - - // obtaining absolute path of this script - var scripts = doc.getElementsByTagName('script'); - var path = scripts[scripts.length - 1].src - .split('?')[0] - .split('/') - .slice(0, -1) - .join('/') + '/'; - - var Sandbox = { - startTimeout() { - this.timeoutId = setTimeout(() => { - this.error = new Error('Plugin failed to initialize'); - this.destroyPlugin(); - }, 3000); - }, - cancelTimout() { - if (this.timeoutId) { - clearTimeout(this.timeoutId); - this.timeoutId = null; - } - }, - createPlugin() { - this.plugin = new jailed.Plugin(path + 'plugin.js'); - this.startTimeout(); - this.plugin.whenConnected(() => { - this.cancelTimout(); - }); - }, - destroyPlugin() { - this.plugin.disconnect(); - } - }; - - - // sends the input to the plugin for evaluation - common.detectLoops$ = function detectLoops$({ code = '', ...rest }) { - return Observable.create(function(observer) { - const sandbox = Object.create(Sandbox); - - sandbox.createPlugin(); - sandbox.plugin.whenConnected(() => { - sandbox.plugin.remote.run(code, (err, data) => { - observer.onNext({ ...rest, err, code, data }); - observer.onCompleted(); - }); - }); - - sandbox.plugin.whenDisconnected(() => { - if (sandbox.disposed) { - return null; - } - - if (sandbox.error) { - observer.onNext({ ...rest, err: sandbox.error, code, data: {} }); - } - observer.onCompleted(); - }); - - return new Disposable(() => { - sandbox.disposed = true; - sandbox.destroyPlugin(); - }); - }); - }; - - return common; -}(window)); diff --git a/client/commonFramework/end.js b/client/commonFramework/end.js index 0c9e914521..811e154192 100644 --- a/client/commonFramework/end.js +++ b/client/commonFramework/end.js @@ -28,18 +28,7 @@ $(document).ready(function() { // only run for HTML .filter(() => common.challengeType === challengeTypes.HTML) .flatMap(code => { - if ( - common.hasJs(code) - ) { - return common.detectUnsafeCode$(code) - .flatMap(code => common.detectLoops$(code)) - .flatMap( - ({ err }) => err ? Observable.throw(err) : Observable.just(code) - ) - .flatMap(code => common.updatePreview$(code)) - .catch(err => Observable.just({ err })); - } - return Observable.just(code) + return common.detectUnsafeCode$(code) .flatMap(code => common.updatePreview$(code)) .catch(err => Observable.just({ err })); }) @@ -66,12 +55,12 @@ $(document).ready(function() { .catch(err => Observable.just({ err })); }) .subscribe( - ({ err, output, original }) => { + ({ err, output, originalCode }) => { if (err) { console.error(err); return common.updateOutputDisplay('' + err); } - common.codeStorage.updateStorage(challengeName, original); + common.codeStorage.updateStorage(challengeName, originalCode); common.updateOutputDisplay('' + output); }, (err) => { @@ -102,11 +91,11 @@ $(document).ready(function() { if (common.challengeType === common.challengeTypes.HTML) { return common.updatePreview$(`

${err}

- `).subscribe(() => {}); + `).first().subscribe(() => {}); } return common.updateOutputDisplay('' + err); } - common.updateOutputDisplay(output); + common.updateOutputDisplay('' + output); common.displayTestResults(tests); if (solved) { common.showCompletion(); @@ -153,12 +142,12 @@ $(document).ready(function() { .flatMap(() => common.executeChallenge$()) .catch(err => Observable.just({ err })) .subscribe( - ({ err, original, tests }) => { + ({ err, originalCode, tests }) => { if (err) { console.error(err); return common.updateOutputDisplay('' + err); } - common.codeStorage.updateStorage(challengeName, original); + common.codeStorage.updateStorage(challengeName, originalCode); common.displayTestResults(tests); }, (err) => { diff --git a/client/commonFramework/execute-challenge-stream.js b/client/commonFramework/execute-challenge-stream.js index 181868e581..21aabb5b31 100644 --- a/client/commonFramework/execute-challenge-stream.js +++ b/client/commonFramework/execute-challenge-stream.js @@ -1,16 +1,17 @@ -// what are the executeChallenge functions? -// Should be responsible for starting after a submit action -// Should not be responsible for displaying results -// Should return results -// should grab editor value -// depends on main editor window.common = (function(global) { const { ga, - Rx: { Observable }, common = { init: [] } } = global; + const { + addLoopProtect, + detectUnsafeCode$, + updatePreview$, + challengeType, + challengeTypes + } = common; + let attempts = 0; common.executeChallenge$ = function executeChallenge$() { @@ -18,67 +19,39 @@ window.common = (function(global) { const originalCode = code; const head = common.arrayToNewLineString(common.head); const tail = common.arrayToNewLineString(common.tail); + const combinedCode = head + code + tail; attempts++; ga('send', 'event', 'Challenge', 'ran-code', common.challengeName); // run checks for unsafe code - return common.detectUnsafeCode$(code) + return detectUnsafeCode$(code) // add head and tail and detect loops - .map(code => head + code + tail) - .flatMap(code => { - if (common.challengeType === common.challengeTypes.HTML) { - - if (common.hasJs(code)) { - // html has a script code - // add faux code and test in webworker - return common.addFaux$(code) - .flatMap(code => common.detectLoops$(code)) - .flatMap(({ err }) => { - if (err) { - return Observable.throw(err); - } - return common.updatePreview$(code) - .flatMap(() => common.runPreviewTests$({ - code, - tests: common.tests.slice() - })); - }); - } - - // no script code detected in html code - // Update preview and run tests in iframe - return common.updatePreview$(code) - .flatMap(code => common.runPreviewTests$({ - code, - tests: common.tests.slice() - })); + .map(() => { + if (challengeType !== challengeTypes.HTML) { + return ``; } - // js challenge - // remove comments and add tests to string - return Observable.just(common.addTestsToString(Object.assign( - { - code: common.removeComments(code), - tests: common.tests.slice() - } - ))) - .flatMap(common.detectLoops$) - .flatMap(({ err, code, data, userTests }) => { - if (err) { - return Observable.throw(err); - } + return addLoopProtect(combinedCode); + }) + .flatMap(code => updatePreview$(code)) + .flatMap(code => { + let output; - // run tests - // for now these are running in the browser - return common.runTests$({ - data, - code, - userTests, - originalCode, - output: data.output.replace(/\\\"/gi, '') - }); + if ( + challengeType === challengeTypes.HTML && + common.hasJs(code) + ) { + output = common.getJsOutput(common.getJsFromHtml(code)); + } else if (challengeType !== challengeTypes.HTML) { + output = common.getJsOutput(combinedCode); + } + + return common.runPreviewTests$({ + tests: common.tests.slice(), + originalCode, + output }); }); }; diff --git a/client/commonFramework/get-iframe.js b/client/commonFramework/get-iframe.js new file mode 100644 index 0000000000..012dce7b50 --- /dev/null +++ b/client/commonFramework/get-iframe.js @@ -0,0 +1,23 @@ +window.common = (function(global) { + const { + common = { init: [] }, + document: doc + } = global; + + common.getIframe = function getIframe(id = 'preview') { + let previewFrame = doc.getElementById(id); + + // create and append a hidden preview frame + if (!previewFrame) { + previewFrame = doc.createElement('iframe'); + previewFrame.id = id; + previewFrame.setAttribute('style', 'display: none'); + doc.body.appendChild(previewFrame); + } + + return previewFrame.contentDocument || + previewFrame.contentWindow.document; + }; + + return common; +})(window); diff --git a/client/commonFramework/init.js b/client/commonFramework/init.js index 09add87bf7..ff4130efd1 100644 --- a/client/commonFramework/init.js +++ b/client/commonFramework/init.js @@ -102,5 +102,19 @@ window.common = (function(global) { }); }; + const openScript = /\<\s?script\s?\>/gi; + const closingScript = /\<\s?\/\s?script\s?\>/gi; + + // detects if there is JavaScript in the first script tag + common.hasJs = function hasJs(code) { + return !!common.getJsFromHtml(code); + }; + + // grabs the content from the first script tag in the code + common.getJsFromHtml = function getJsFromHtml(code) { + // grab user javaScript + return (code.split(openScript)[1] || '').split(closingScript)[0] || ''; + }; + return common; })(window); diff --git a/client/commonFramework/update-preview.js b/client/commonFramework/update-preview.js index 8e2b7d8bcb..744c73e2b2 100644 --- a/client/commonFramework/update-preview.js +++ b/client/commonFramework/update-preview.js @@ -4,7 +4,13 @@ window.common = (function(global) { common = { init: [] } } = global; + // the first script tag here is to proxy jQuery + // We use the same jQuery on the main window but we change the + // context to that of the iframe. var libraryIncludes = ` + Observable.throw({ err: new Error('run preview not enabled') }); + () => Observable.throw(new Error('run preview not enabled')); common.updatePreview$ = function updatePreview$(code = '') { - const previewFrame = document.getElementById('preview'); - const preview = previewFrame.contentDocument || - previewFrame.contentWindow.document; - if (!preview) { - return Observable.just(code); - } + const preview = common.getIframe('preview'); return iFrameScript$ .map(script => ``) @@ -55,7 +56,10 @@ window.common = (function(global) { // now we filter false values and wait for the first true return common.previewReady$ .filter(ready => ready) - .first(); + .first() + // the delay here is to give code within the iframe + // control to run + .delay(100); }) .map(() => code); }; diff --git a/client/faux.js b/client/faux.js deleted file mode 100644 index ca4563f0d8..0000000000 --- a/client/faux.js +++ /dev/null @@ -1,275 +0,0 @@ -/* eslint-disable no-unused-vars */ -var document = {}; -var navigator = function() { - this.geolocation = function() { - this.getCurrentPosition = function() { - this.coords = {latitude: '', longitude: '' }; - return this; - }; - return this; - }; - return this; -}; -function $() { - if (!(this instanceof $)) { - return new $(); - } -} -function returnThis() { return this; } -function return$() { return $; } -var methods = [ - 'add', - 'addBack', - 'addClass', - 'after', - 'ajaxComplete', - 'ajaxError', - 'ajaxSend', - 'ajaxStart', - 'ajaxStop', - 'ajaxSuccess', - 'andSelf', - 'animate', - 'append', - 'appendTo', - 'attr', - 'before', - 'bind', - 'blur', - 'callbacksadd', - 'callbacksdisable', - 'callbacksdisabled', - 'callbacksempty', - 'callbacksfire', - 'callbacksfired', - 'callbacksfireWith', - 'callbackshas', - 'callbackslock', - 'callbackslocked', - 'callbacksremove', - 'change', - 'children', - 'clearQueue', - 'click', - 'clone', - 'closest', - 'contents', - 'context', - 'css', - 'data', - 'dblclick', - 'delay', - 'delegate', - 'dequeue', - 'detach', - 'die', - 'each', - 'empty', - 'end', - 'eq', - 'error', - 'fadeIn', - 'fadeOut', - 'fadeTo', - 'fadeToggle', - 'filter', - 'find', - 'finish', - 'first', - 'focus', - 'focusin', - 'focusout', - 'get', - 'has', - 'hasClass', - 'height', - 'hide', - 'hover', - 'html', - 'index', - 'innerHeight', - 'innerWidth', - 'insertAfter', - 'insertBefore', - 'is', - 'jQuery', - 'jquery', - 'keydown', - 'keypress', - 'keyup', - 'last', - 'length', - 'live', - 'load', - 'load', - 'map', - 'mousedown', - 'mouseenter', - 'mouseleave', - 'mousemove', - 'mouseout', - 'mouseover', - 'mouseup', - 'next', - 'nextAll', - 'nextUntil', - 'not', - 'off', - 'offset', - 'offsetParent', - 'on', - 'one', - 'outerHeight', - 'outerWidth', - 'parent', - 'parents', - 'parentsUntil', - 'position', - 'prepend', - 'prependTo', - 'prev', - 'prevAll', - 'prevUntil', - 'promise', - 'prop', - 'pushStack', - 'queue', - 'ready', - 'remove', - 'removeAttr', - 'removeClass', - 'removeData', - 'removeProp', - 'replaceAll', - 'replaceWith', - 'resize', - 'scroll', - 'scrollLeft', - 'scrollTop', - 'select', - 'selector', - 'serialize', - 'serializeArray', - 'show', - 'siblings', - 'size', - 'slice', - 'slideDown', - 'slideToggle', - 'slideUp', - 'stop', - 'submit', - 'text', - 'toArray', - 'toggle', - 'toggle', - 'toggleClass', - 'trigger', - 'triggerHandler', - 'unbind', - 'undelegate', - 'unload', - 'unwrap', - 'val', - 'width', - 'wrap', - 'wrapAll', - 'wrapInner' -]; - -var statics = [ - 'ajax', - 'ajaxPrefilter', - 'ajaxSetup', - 'ajaxTransport', - 'boxModel', - 'browser', - 'Callbacks', - 'contains', - 'cssHooks', - 'cssNumber', - 'data', - 'Deferred', - 'dequeue', - 'each', - 'error', - 'extend', - 'fnextend', - 'fxinterval', - 'fxoff', - 'get', - 'getJSON', - 'getScript', - 'globalEval', - 'grep', - 'hasData', - 'holdReady', - 'inArray', - 'isArray', - 'isEmptyObject', - 'isFunction', - 'isNumeric', - 'isPlainObject', - 'isWindow', - 'isXMLDoc', - 'makeArray', - 'map', - 'merge', - 'noConflict', - 'noop', - 'now', - 'param', - 'parseHTML', - 'parseJSON', - 'parseXML', - 'post', - 'proxy', - 'queue', - 'removeData', - 'sub', - 'support', - 'trim', - 'type', - 'unique', - 'when', - 'always', - 'done', - 'fail', - 'isRejected', - 'isResolved', - 'notify', - 'notifyWith', - 'pipe', - 'progress', - 'promise', - 'reject', - 'rejectWith', - 'resolve', - 'resolveWith', - 'state', - 'then', - 'currentTarget', - 'data', - 'delegateTarget', - 'isDefaultPrevented', - 'isImmediatePropagationStopped', - 'isPropagationStopped', - 'metaKey', - 'namespace', - 'pageX', - 'pageY', - 'preventDefault', - 'relatedTarget', - 'result', - 'stopImmediatePropagation', - 'stopPropagation', - 'target', - 'timeStamp', - 'type', - 'which' -]; - -var $Proto = {}; -methods.forEach(method => $Proto[method] = returnThis); -statics.forEach(staticMeth => $[staticMeth] = return$); -$.prototype = Object.create($); diff --git a/client/iFrameScripts.js b/client/iFrameScripts.js index 7bbaa3eb10..3011d4272e 100644 --- a/client/iFrameScripts.js +++ b/client/iFrameScripts.js @@ -1,21 +1,43 @@ /* eslint-disable no-undef, no-unused-vars, no-native-reassign */ -window.__$ = parent.$; -window.__$(function() { +// the $ on the iframe window object is the same +// as the one used on the main site, but +// uses the iframe document as the context +window.$(document).ready(function() { var _ = parent._; var Rx = parent.Rx; var chai = parent.chai; var assert = chai.assert; var tests = parent.tests; var common = parent.common; - var editor = common.editor.getValue(); - // change the context of $ so it uses the iFrame for testing - var $ = __$.proxy(__$.fn.find, __$(document)); + + common.getJsOutput = function evalJs(code = '') { + let output; + try { + /* eslint-disable no-eval */ + output = eval(code); + /* eslint-enable no-eval */ + } catch (e) { + window.__err = e; + } + return output; + }; common.runPreviewTests$ = - function runPreviewTests$({ tests = [], ...rest }) { + function runPreviewTests$({ + tests = [], + originalCode, + ...rest + }) { + const code = originalCode; + const editor = { getValue() { return originalCode; } }; + if (window.__err) { + return Rx.Observable.throw(window.__err); + } + return Rx.Observable.from(tests) .map(test => { const userTest = {}; + common.appendToOutputDisplay(''); try { /* eslint-disable no-eval */ eval(test); @@ -32,12 +54,13 @@ window.__$(function() { return userTest; }) .toArray() - .map(tests => ({ ...rest, tests })); + .map(tests => ({ ...rest, tests, originalCode })); }; // now that the runPreviewTest$ is defined // we set the subject to true // this will let the updatePreview // script now that we are ready. + console.log('second'); common.previewReady$.onNext(true); }); diff --git a/gulpfile.js b/gulpfile.js index 3720c9ad72..b8311461b3 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -18,6 +18,7 @@ var Rx = require('rx'), uglify = require('gulp-uglify'), merge = require('merge-stream'), babel = require('gulp-babel'), + sourcemaps = require('gulp-sourcemaps'), // react app webpack = require('webpack-stream'), @@ -85,7 +86,8 @@ var paths = { 'public/bower_components/CodeMirror/mode/xml/xml.js', 'public/bower_components/CodeMirror/mode/css/css.js', 'public/bower_components/CodeMirror/mode/htmlmixed/htmlmixed.js', - 'node_modules/emmet-codemirror/dist/emmet.js' + 'node_modules/emmet-codemirror/dist/emmet.js', + 'public/js/lib/loop-protect/loop-protect.js' ], vendorMain: [ @@ -103,20 +105,20 @@ var paths = { js: [ 'client/main.js', 'client/iFrameScripts.js', - 'client/plugin.js', - 'client/faux.js' + 'client/plugin.js' ], commonFramework: [ 'init', 'bindings', 'add-test-to-string', - 'add-faux-stream', 'code-storage', 'code-uri', + 'add-loop-protect', + 'get-iframe', + 'update-preview', 'create-editor', 'detect-unsafe-code-stream', - 'detect-loops-stream', 'display-test-results', 'execute-challenge-stream', 'output-display', @@ -125,7 +127,6 @@ var paths = { 'run-tests-stream', 'show-completion', 'step-challenge', - 'update-preview', 'end' ], @@ -434,7 +435,9 @@ gulp.task('dependents', ['js'], function() { return gulp.src(formatCommonFrameworkPaths.call(paths.commonFramework)) .pipe(plumber({ errorHandler: errorHandler })) .pipe(babel()) + .pipe(__DEV__ ? sourcemaps.init() : gutil.noop()) .pipe(concat('commonFramework.js')) + .pipe(__DEV__ ? sourcemaps.write() : gutil.noop()) .pipe(__DEV__ ? gutil.noop() : uglify()) .pipe(revReplace({ manifest: manifest })) .pipe(gulp.dest(dest)) diff --git a/package.json b/package.json index 6a9b3990dc..01be061ba0 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "browser-sync": "^2.9.12", "chai": "^3.4.0", "envify": "^3.4.0", + "gulp-sourcemaps": "^1.6.0", "istanbul": "~0.4.0", "jsonlint": "^1.6.2", "loopback-component-explorer": "^2.1.1", diff --git a/public/js/lib/loop-protect/LICENSE.md b/public/js/lib/loop-protect/LICENSE.md new file mode 100644 index 0000000000..9c4f96a8ec --- /dev/null +++ b/public/js/lib/loop-protect/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 JS Bin Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/public/js/lib/loop-protect/loop-protect.js b/public/js/lib/loop-protect/loop-protect.js new file mode 100644 index 0000000000..5050605b9e --- /dev/null +++ b/public/js/lib/loop-protect/loop-protect.js @@ -0,0 +1,432 @@ +if (typeof DEBUG === 'undefined') { DEBUG = true; } + +(function (root, factory) { + 'use strict'; + /*global define*/ + if (typeof define === 'function' && define.amd) { + define(factory(root)); + } else if (typeof exports === 'object') { + module.exports = factory(root); + } else { + root.loopProtect = factory(root); + } +})(this, function loopProtectModule(root) { + /*global DEBUG*/ + 'use strict'; + var debug = null; + + // the standard loops - note that recursive is not supported + var re = /\b(for|while|do)\b/g; + var reSingle = /\b(for|while|do)\b/; + var labelRe = /\b([a-z_]{1}\w+:)/i; + var comments = /(?:\/\*(?:[\s\S]*?)\*\/)|(?:([\s;])+\/\/(?:.*)$)/gm; + + var loopProtect = rewriteLoops; + + // used in the loop detection + loopProtect.counters = {}; + + // expose debug info + loopProtect.debug = function debugSwitch(state) { + debug = state ? function () { + console.log.apply(console, [].slice.apply(arguments)); + } : function () {}; + }; + + loopProtect.debug(false); // off by default + + // the method - as this could be aliased to something else + loopProtect.alias = 'loopProtect'; + + function inMultilineComment(lineNum, lines) { + if (lineNum === 0) { + return false; + } + + var j = lineNum; + var closeCommentTags = 1; // let's assume we're inside a comment + var closePos = -1; + var openPos = -1; + + do { + j -= 1; + DEBUG && debug('looking backwards ' + lines[j]); // jshint ignore:line + closePos = lines[j].indexOf('*/'); + openPos = lines[j].indexOf('/*'); + + if (closePos !== -1) { + closeCommentTags++; + } + + if (openPos !== -1) { + closeCommentTags--; + + if (closeCommentTags === 0) { + DEBUG && debug('- exit: part of a multiline comment'); // jshint ignore:line + return true; + } + } + } while (j !== 0); + + return false; + } + + function inCommentOrString(index, line) { + var character; + while (--index > -1) { + character = line.substr(index, 1); + if (character === '"' || character === '\'' || character === '.') { + // our loop keyword was actually either in a string or a property, so let's exit and ignore this line + DEBUG && debug('- exit: matched inside a string or property key'); // jshint ignore:line + return true; + } + if (character === '/' || character === '*') { + // looks like a comment, go back one to confirm or not + --index; + if (character === '/') { + // we've found a comment, so let's exit and ignore this line + DEBUG && debug('- exit: part of a comment'); // jshint ignore:line + return true; + } + } + } + return false; + } + + function directlyBeforeLoop(index, lineNum, lines) { + reSingle.lastIndex = 0; + labelRe.lastIndex = 0; + var beforeLoop = false; + + var theRest = lines.slice(lineNum).join('\n').substr(index).replace(labelRe, ''); + theRest.replace(reSingle, function commentStripper(match, capture, i) { + var target = theRest.substr(0, i).replace(comments, '').trim(); + DEBUG && debug('- directlyBeforeLoop: ' + target); // jshint ignore:line + if (target.length === 0) { + beforeLoop = true; + } + // strip comments out of the target, and if there's nothing else + // it's a valid label...I hope! + }); + + return beforeLoop; + } + + /** + * Look for for, while and do loops, and inserts *just* at the start of the + * loop, a check function. + */ + function rewriteLoops(code, offset) { + var recompiled = []; + var lines = code.split('\n'); + var disableLoopProtection = false; + var method = loopProtect.alias + '.protect'; + var ignore = {}; + var pushonly = {}; + var labelPostion = null; + + function insertReset(lineNum, line, matchPosition) { + // recompile the line with the reset **just** before the actual loop + // so that we insert in to the correct location (instead of possibly + // outside the logic + return line.slice(0, matchPosition) + ';' + method + '({ line: ' + lineNum + ', reset: true }); ' + line.slice(matchPosition); + }; + + if (!offset) { + offset = 0; + } + + lines.forEach(function eachLine(line, lineNum) { + // reset our regexp each time. + re.lastIndex = 0; + labelRe.lastIndex = 0; + + if (disableLoopProtection) { + return; + } + + if (line.toLowerCase().indexOf('noprotect') !== -1) { + disableLoopProtection = true; + } + + var index = -1; + var matchPosition = -1; + var originalLineNum = lineNum; + // +1 since we're humans and don't read lines numbers from zero + var printLineNumber = lineNum - offset + 1; + var character = ''; + // special case for `do` loops, as they're end with `while` + var dofound = false; + var findwhile = false; + var terminator = false; + var matches = line.match(re) || []; + var match = matches.length ? matches[0] : ''; + var labelMatch = line.match(labelRe) || []; + var openBrackets = 0; + var openBraces = 0; + + if (labelMatch.length) { + DEBUG && debug('- label match'); // jshint ignore:line + index = line.indexOf(labelMatch[1]); + if (!inCommentOrString(index, line)) { + if (!inMultilineComment(lineNum, lines)) { + if (directlyBeforeLoop(index, lineNum, lines)) { + DEBUG && debug('- found a label: "' + labelMatch[0] + '"'); // jshint ignore:line + labelPostion = lineNum; + } else { + DEBUG && debug('- ignored "label", false positive'); // jshint ignore:line + } + } else { + DEBUG && debug('- ignored label in multline comment'); // jshint ignore:line + } + } else { + DEBUG && debug('- ignored label in string or comment'); // jshint ignore:line + } + } + + if (ignore[lineNum]) { + DEBUG && debug(' -exit: ignoring line ' + lineNum +': ' + line); // jshint ignore:line + return; + } + + if (pushonly[lineNum]) { + DEBUG && debug('- exit: ignoring, but adding line ' + lineNum + ': ' + line); // jshint ignore:line + recompiled.push(line); + return; + } + + // if there's more than one match, we just ignore this kind of loop + // otherwise I'm going to be writing a full JavaScript lexer...and god + // knows I've got better things to be doing. + if (match && matches.length === 1 && line.indexOf('jsbin') === -1) { + DEBUG && debug('match on ' + match + '\n'); // jshint ignore:line + + // there's a special case for protecting `do` loops, we need to first + // prtect the `do`, but then ignore the closing `while` statement, so + // we reset the search state for this special case. + dofound = match === 'do'; + + // make sure this is an actual loop command by searching backwards + // to ensure it's not a string, comment or object property + matchPosition = index = line.indexOf(match); + + // first we need to walk backwards to ensure that our match isn't part + // of a string or part of a comment + if (inCommentOrString(index, line)) { + recompiled.push(line); + return; + } + + // it's quite possible we're in the middle of a multiline + // comment, so we'll cycle up looking for an opening comment, + // and if there's one (and not a closing `*/`), then we'll + // ignore this line as a comment + if (inMultilineComment(lineNum, lines)) { + recompiled.push(line); + return; + } + + // now work our way forward to look for '{' + index = line.indexOf(match) + match.length; + + if (index === line.length) { + if (index === line.length && lineNum < (lines.length-1)) { + // move to the next line + DEBUG && debug('- moving to next line'); // jshint ignore:line + recompiled.push(line); + lineNum++; + line = lines[lineNum]; + ignore[lineNum] = true; + index = 0; + } + + } + + while (index < line.length) { + character = line.substr(index, 1); + // DEBUG && debug(character, index); // jshint ignore:line + + if (character === '(') { + openBrackets++; + } + + if (character === ')') { + openBrackets--; + + if (openBrackets === 0 && terminator === false) { + terminator = index; + } + } + + if (character === '{') { + openBraces++; + } + + if (character === '}') { + openBraces--; + } + + if (openBrackets === 0 && (character === ';' || character === '{')) { + // if we're a non-curlies loop, then convert to curlies to get our code inserted + if (character === ';') { + if (lineNum !== originalLineNum) { + DEBUG && debug('- multiline inline loop'); // jshint ignore:line + // affect the compiled line + recompiled[originalLineNum] = recompiled[originalLineNum].substring(0, terminator + 1) + '{\nif (' + method + '({ line: ' + printLineNumber + ' })) break;\n' + recompiled[originalLineNum].substring(terminator + 1); + line += '\n}\n'; + } else { + // simpler + DEBUG && debug('- single line inline loop'); // jshint ignore:line + line = line.substring(0, terminator + 1) + '{\nif (' + method + '({ line: ' + printLineNumber + ' })) break;\n' + line.substring(terminator + 1) + '\n}\n'; + } + + } else if (character === '{') { + DEBUG && debug('- multiline with braces'); // jshint ignore:line + var insert = ';\nif (' + method + '({ line: ' + printLineNumber + ' })) break;\n'; + line = line.substring(0, index + 1) + insert + line.substring(index + 1); + + index += insert.length; + } + + // work out where to put the reset + if (lineNum === originalLineNum && labelPostion === null) { + DEBUG && debug('- simple reset insert'); // jshint ignore:line + line = insertReset(printLineNumber, line, matchPosition); + index += (';' + method + '({ line: ' + lineNum + ', reset: true }); ').length; + } else { + // insert the reset above the originalLineNum OR if this loop used + // a label, we have to insert the reset *above* the label + if (labelPostion === null) { + DEBUG && debug('- reset inserted above original line'); // jshint ignore:line + recompiled[originalLineNum] = insertReset(printLineNumber, recompiled[originalLineNum], matchPosition); + } else { + DEBUG && debug('- reset inserted above matched label on line ' + labelPostion); // jshint ignore:line + if (recompiled[labelPostion] === undefined) { + labelPostion--; + matchPosition = 0; + } + recompiled[labelPostion] = insertReset(printLineNumber, recompiled[labelPostion], matchPosition); + labelPostion = null; + } + } + + recompiled.push(line); + + if (!dofound) { + return; + } else { + DEBUG && debug('searching for closing `while` statement for: ' + line); // jshint ignore:line + // cycle forward until we find the close brace, after which should + // be our while statement to ignore + findwhile = false; + while (index < line.length) { + character = line.substr(index, 1); + + if (character === '{') { + openBraces++; + } + + if (character === '}') { + openBraces--; + } + + if (openBraces === 0) { + findwhile = true; + } else { + findwhile = false; + } + + if (openBraces === 0) { + DEBUG && debug('outside of closure, looking for `while` statement: ' + line); // jshint ignore:line + } + + if (findwhile && line.indexOf('while') !== -1) { + DEBUG && debug('- exit as we found `while`: ' + line); // jshint ignore:line + pushonly[lineNum] = true; + return; + } + + index++; + + if (index === line.length && lineNum < (lines.length-1)) { + lineNum++; + line = lines[lineNum]; + DEBUG && debug(line); // jshint ignore:line + index = 0; + } + } + return; + } + } + + index++; + + if (index === line.length && lineNum < (lines.length-1)) { + // move to the next line + DEBUG && debug('- moving to next line'); // jshint ignore:line + recompiled.push(line); + lineNum++; + line = lines[lineNum]; + ignore[lineNum] = true; + index = 0; + } + } + } else { + // else we're a regular line, and we shouldn't be touched + DEBUG && debug('regular line ' + line); // jshint ignore:line + recompiled.push(line); + } + }); + + DEBUG && debug('---- source ----'); // jshint ignore:line + DEBUG && debug(code); // jshint ignore:line + DEBUG && debug('---- rewrite ---'); // jshint ignore:line + DEBUG && debug(recompiled.join('\n')); // jshint ignore:line + DEBUG && debug(''); // jshint ignore:line + + return disableLoopProtection ? code : recompiled.join('\n'); + }; + + /** + * Injected code in to user's code to **try** to protect against infinite + * loops cropping up in the code, and killing the browser. Returns true + * when the loops has been running for more than 100ms. + */ + loopProtect.protect = function protect(state) { + loopProtect.counters[state.line] = loopProtect.counters[state.line] || {}; + var line = loopProtect.counters[state.line]; + var now = (new Date()).getTime(); + + if (state.reset) { + line.time = now; + line.hit = 0; + line.last = 0; + } + + line.hit++; + if ((now - line.time) > 100) {//} && line.hit !== line.last+1) { + // We've spent over 100ms on this loop... smells infinite. + loopProtect.hit(state.line); + // Returning true prevents the loop running again + return true; + } + line.last++; + return false; + }; + + loopProtect.hit = function hit(line) { + var msg = 'Exiting potential infinite loop at line ' + line + '. To disable loop protection: add "// noprotect" to your code'; + if (root.proxyConsole) { + root.proxyConsole.error(msg); + } else { + console.error(msg); + } + }; + + loopProtect.reset = function reset() { + // reset the counters + loopProtect.counters = {}; + }; + + return loopProtect; +});