UNB/ CS/ David Bremner/ teaching/ cs2613/ books/ eloquent-javascript/ js/ sandbox.js
(function() {
  "use strict"

  function parseStack(stack) {
    let found = [], m
    let re = /([\w$]*)@(.*?):(\d+)|\bat (?:([^\s(]+) \()?(.*?):(\d+)/g
    while (m = re.exec(stack)) {
      let fn = m[1] || m[4] || null
      let file = m[2] || m[5] || null
      if (fn && /sandbox/i.test(fn) || file && /sandbox/i.test(file)) break
      found.push({fn, file, line: m[3] || m[6]})
    }
    return found
  }
  function frameString(frame) {
    return "line " + frame.line + (frame.fn ? " in function " + frame.fn : "")
  }

  let SandBox = window.SandBox = class {
    static create(options) {
      return new Promise(done => {
        let frame = document.createElement("iframe")
        frame.addEventListener("load", loaded)
        frame.src = "empty.html"
        if (options.place) {
          options.place(frame)
        } else {
          frame.style.display = "none"
          document.body.appendChild(frame)
        }

        function loaded() {
          frame.removeEventListener("load", loaded)
          let box = new SandBox(frame)

          promiseChain((options.loadFiles || []).map(file => () => {
            let script = box.win.document.createElement("script")
            script.src = file
            box.win.document.body.appendChild(script)
            return new Promise(done => script.addEventListener("load", done))
          })).then(() => {
            if (box.notify.onLoad) box.notify.onLoad()
            done(box)
          })
        }
      })
    }

    constructor(frame) {
      this.startedAt = null
      this.extraSecs = 2
      this.output = null
      this.handleDeps = true
      this.notify = {}

      this.callbacks = {}
      // Used to cancel existing events when new code is loaded
      this.timeouts = []; this.intervals = []; this.frames = []
      this.framePos = 0

      // Loaded CommonJS modules
      this.loaded = window.loadedModules = new Cached(name => resolved.compute(name).then(({name, code}) => this.evalModule(name, code)))

      this.frame = frame
      this.win = frame.contentWindow
      this.setupEnv()

      const resize = () => {
        if (this.frame.style.display != "none") this.resizeFrame()
      }
      this.frame.addEventListener("load", resize)
      let resizeTimeout = null
      const scheduleResize = () => {
        this.win.clearTimeout(resizeTimeout)
        this.win.__setTimeout(resize, 200)
      }
      this.win.addEventListener("keydown", scheduleResize)
      this.win.addEventListener("mousedown", scheduleResize)
    }

    run(code, output, meta) {
      if (output) this.output = output
      this.startedAt = Date.now()
      this.extraSecs = typeof code == "string" && /promtDirection/.test(code) ? 0.1 : 2
      this.win.__c = 0
      this.prepare(code)
        .then(code => {
          if (code instanceof Function) return code()
          let value = this.win.eval(code)
          if (this.notify.onRun) this.notify.onRun(code, meta)
          return value
        })
        .catch(err => this.error(err))
    }

    prepare(text) {
      let {code, dependencies} = preprocess(text, this)
      return (this.handleDeps ? Promise.all(dependencies.map(dep => this.loaded.compute(dep))) : Promise.resolve([])).then(() => code)
    }

    evalModule(name, code) {
      if (/\.json$/.test(name))
        return this.loaded.store(name, {exports: JSON.parse(code)})

      let work = findDeps(code).map(dep => this.loaded.compute(resolveRelative(name, dep)))
      return Promise.all(work).then(() => {
        let f = new this.win.Function("require, exports, module, __dirname, __filename",
                                      code + "\n//# sourceURL=code" + randomID())
        let module = this.loaded.store(name, {exports: {}})
        f(dep => this.require(resolveRelative(name, dep)), module.exports, module, name, name)
        return module
      })
    }

    require(name) {
      let found = resolved.get(name)
      if (!found) throw new Error(`Could not load module '${name}'`)
      return this.loaded.get(found.name).exports
    }

    setHTML(code, output, callback) {
      this.clearEvents()
      let loc = String(this.win.document.location)
      if (loc != String(document.location) && !/\/empty\.html$/.test(loc)) {
        this.frame.src = "empty.html"
        let loaded = () => {
          this.frame.removeEventListener("load", loaded)
          this.setupEnv()
          this.setHTML(code, output, callback)
        }
        this.frame.addEventListener("load", loaded)
        return
      }

      let scriptTags = [], sandbox = this, doc = this.win.document
      this.frame.style.display = "block"
      doc.documentElement.innerHTML = code.replace(/<script\b[^>]*?(?:\bsrc\s*=\s*('[^']+'|"[^"]+"|[^\s>]+)[^>]*)?>([\s\S]*?)<\/script>/g, function(_, src, content) {
        let tag = doc.createElement("script")
        if (src) {
          if (/["']/.test(src.charAt(0))) src = src.slice(1, src.length - 1)
          tag.src = src
        } else {
          tag.text = preprocess(content, sandbox).code
        }
        scriptTags.push(tag)
        return ""
      })

      this.frame.style.height = "80px"
      this.resizeFrame()
      if (output) this.output = output

      function loadScript(i) {
        if (i == scriptTags.length) {
          if (i) setTimeout(() => {sandbox.resizeFrame()}, 50)
          callback && callback()
          return
        }

        sandbox.startedAt = Date.now()
        sandbox.extraSecs = 2
        sandbox.win.__c = 0
        let tag = scriptTags[i]
        if (tag.src) {
          tag.addEventListener("load", () => { loadScript(i + 1) })
        } else {
          let id = randomID()
          sandbox.callbacks[id] = () => { delete sandbox.callbacks[id]; loadScript(i + 1) }
          tag.text += "\n;__sandbox.callbacks['" + id + "']();"
        }
        doc.body.appendChild(tag)
      }
      loadScript(0)
    }

    setupEnv() {
      let win = this.win
      win.__sandbox = this

      win.onerror = (e, _file, line) => {
        if (!this.output) return
        this.output.out("error", [e + (line != null ? " (line " + line + ")" : "")])
        return true
      }
      win.console = {
        log: (...args) => this.out("log", args),
        error: (...args) => this.out("error", args),
        warn: (...args) => this.out("warn", args),
        info: (...args) => this.out("log", args)
      }
      win.setInterval(() => {
        this.startedAt = null
      }, 1000)

      win.__setTimeout = win.setTimeout
      win.__setInterval = win.setInterval
      win.setTimeout = (code, time, ...args) => {
        if (args.length && typeof code != "string") {
          let f = code
          code = () => f(...args)
        }
        let val = win.__setTimeout(() => this.run(code), time)
        this.timeouts.push(val)
        return val
      }
      win.setInterval = (code, time, ...args) => {
        if (args.length && typeof code != "string") {
          let f = code
          code = () => f(...args)
        }
        let val = win.__setInterval(() => this.run(code), time)
        this.intervals.push(val)
        return val
      }
      let reqAnimFrame = win.requestAnimationFrame
      if (!reqAnimFrame) ["webkit", "moz", "ms", "o"].forEach(prefix => {
        let val = win[prefix + "RequestAnimationFrame"]
        if (val) {
          reqAnimFrame = val
          win.cancelAnimationFrame = prefix + "CancelAnimationFrame"
        }
      })
      if (!reqAnimFrame) {
        reqAnimFrame = f => this.__setTimeout(f, 50)
        win.cancelAnimationFrame = win.clearTimeout
      }
      win.requestAnimationFrame = f => {
        let val = reqAnimFrame.call(win, f)
        if (this.frames.length > 50)
          this.frames[this.framePos++ % 50] = val
        else
          this.frames.push(val)
        return val
      }

      win.addEventListener("unhandledrejection", e => this.error(e.reason))

      win.require = name => this.require(name)
      win.require.preload = (name, code) => resolved.store(name, {name, code})
      win.module = {exports: {}}
      win.exports = win.module.exports
    }

    resizeFrame() {
      this.frame.style.height = Math.max(80, Math.min(this.win.document.documentElement.offsetHeight + 10, 500)) + "px"
      let box = this.frame.getBoundingClientRect()
      if (box.bottom > box.top && box.top >= 0 && box.top < window.innerHeight && box.bottom > window.innerHeight)
        window.scrollBy(0, Math.min(box.top, box.bottom - window.innerHeight))
    }

    tick() {
      let now = Date.now()
      if (this.startedAt == null) this.startedAt = now
      if (now < this.startedAt + this.extraSecs * 1000) return
      let bail = confirm("This code has been running for " + this.extraSecs +
                         " seconds. Abort it?")
      this.startedAt += Date.now() - now
      this.extraSecs += Math.max(this.extraSecs, 8)
      if (bail) throw new Error("Aborted")
    }

    out(type, args) {
      if (this.output) this.output.out(type, args)
      else console[type].apply(console, args)
    }

    error(exception) {
      if (!this.output) throw exception
      let stack = parseStack(exception.stack)
      this.output.out("error", [String(exception) + (stack.length ? " (" + frameString(stack[0]) + ")" : "")])
      if (stack.length > 1) {
        this.output.div.lastChild.appendChild(document.createTextNode(" "))
        let mark = this.output.div.lastChild.appendChild(document.createElement("span"))
        mark.innerHTML = "…"
        mark.className = "sandbox-output-etc"
        mark.addEventListener("click", () => {
          mark.className = ""
          mark.innerHTML = "\n called from " + stack.slice(1).map(frameString).join("\n called from ")
        })
      }
    }

    clearEvents() {
      while (this.timeouts.length) this.win.clearTimeout(this.timeouts.pop())
      while (this.intervals.length) this.win.clearInterval(this.intervals.pop())
      while (this.frames.length) this.win.cancelAnimationFrame(this.frames.pop())
      this.timeouts.length = this.intervals.length = this.frames.length = this.framePos = 0
    }
  }

  function preprocess(code, sandbox) {
    if (typeof code != "string") {
      if (code.apply) {
        let orig = code
        code = (...args) => {
          try { return orig.apply(null, args) }
          catch(e) { sandbox.error(e) }
        }
      }
      return {code, dependencies: []}
    }

    if (!/\n$/.test(code)) code += "\n"
    let strict = /^(\s|\/\/.*)*["']use strict['"]/.test(code), ast
    try { ast = acorn.parse(code, {sourceType: detectSourceType(code)}) }
    catch(e) { return {code, dependencies: []} }
    let patches = []
    let backJump = ";if (++__c % 1000 === 0) __sandbox.tick();"
    function loop(node) {
      if (node.body.type == "BlockStatement") {
        patches.push({from: node.body.end - 1, text: backJump})
      } else {
        patches.push({from: node.body.start, text: "{"},
                     {from: node.body.end, text: backJump + "}"})
      }
    }
    let dependencies = []

    acorn.walk.simple(ast, {
      ForStatement: loop,
      ForInStatement: loop,
      WhileStatement: loop,
      DoWhileStatement: loop,
      CallExpression(node) {
        if (node.callee.type == "Identifier" && node.callee.name == "require" &&
            node.arguments.length == 1 && node.arguments[0].type == "Literal" &&
            typeof node.arguments[0].value == "string" && !dependencies.includes(node.arguments[0].value))
          dependencies.push(node.arguments[0].value)
      },
      ImportDeclaration(node) {
        dependencies.push(node.source.value)
        let req = "require(" + node.source.raw + ")", text
        if (node.specifiers.length == 0) {
          text = req
        } else if (node.specifiers.length > 1 || node.specifiers[0].type == "ImportDefaultSpecifier") {
          let name = "m_" + node.source.value.replace(/\W+/g, "_") + "__"
          text = "var " + name + " = " + req
          node.specifiers.forEach(spec => {
            if (spec.type == "ImportDefaultSpecifier")
              text += ", " + spec.local.name + " = " + name + ".default || " + name
            else if (name != null)
              text += ", " + spec.local.name + " = " + name + "." + spec.imported.name
          })
        } else {
          text = "var "
          node.specifiers.forEach(spec => {
            if (spec.type == "ImportNamespaceSpecifier")
              text += spec.local.name + " = " + req
            else
              text += spec.local.name + " = " + req + "." + spec.imported.name
          })
        }
        patches.push({from: node.start, to: node.end, text: text + ";"})
      },
      ExportNamedDeclaration(node) {
        if (node.source || !node.declaration)
          patches.push({from: node.start, to: node.end, text: ""})
        else
          patches.push({from: node.start, to: node.declaration.start, text: ""})
      },
      ExportDefaultDeclaration(node) {
        if (/Declaration/.test(node.declaration.type)) {
          patches.push({from: node.start, to: node.declaration.start, text: ""})
        } else {
          patches.push({from: node.start, to: node.declaration.start, text: ";("},
                       {from: node.declaration.end, text: ")"})
        }
      },
      ExportAllDeclaration: function(node) {
        patches.push({from: node.start, to: node.end, text: ""})
      }
    })

    let tryPos = 0, catchPos = ast.end
    for (let i = strict ? 1 : 0; i < ast.body.length; i++) {
      let stat = ast.body[i]
      if (stat.type != "FunctionDeclaration") {
        if (tryPos == 0) tryPos = stat.start
        catchPos = stat.end
      }
      if (stat.type == "VariableDeclaration" && stat.kind != "var") {
        if (stat.kind == "const") {
          let found = findAssignmentsTo(stat.declarations, ast)
          if (found) patches.push({from: 0, text: `throw new TypeError("invalid assignment to const '${found}'");`})
        }
        patches.push({from: stat.start, to: stat.start + stat.kind.length, text: "var"})
      }
      if (stat.type == "ClassDeclaration")
        patches.push({from: stat.start, text: "var " + stat.id.name + " = "})
    }

    patches.push({from: tryPos, text: "try{", priority: 10})
    patches.push({from: catchPos, text: "}catch(e){__sandbox.error(e);}", priority: -10})
    patches.sort((a, b) => a.from - b.from || (a.to || a.from) - (b.to || b.from) || (b.priority || 0) - (a.priority || 0))
    let out = "", pos = 0
    for (let i = 0; i < patches.length; ++i) {
      let patch = patches[i]
      out += code.slice(pos, patch.from) + patch.text
      pos = patch.to || patch.from
    }
    out += code.slice(pos, code.length)
    out += "\n//# sourceURL=code" + randomID()
    return {code: (strict ? '"use strict";' : "") + out, dependencies}
  }

  function findAssignmentsTo(decls, ast) {
    let found = null
    for (let i = 0; i < decls.length; i++)
      acorn.walk.simple(decls[i].id, {
        VariablePattern(node) { if (findAssignments(node.name, ast)) found = node.name }
      }, null, null, "Pattern")
    return found
  }

  function findAssignments(name, ast) {
    let found = false
    acorn.walk.ancestor(ast, {
      AssignmentExpression(node, ancestors) {
        if (node.left.type != "Identifier" || node.left.name != name) return
        for (let i = 2; i < ancestors.length; i++) if (/Statement|Declaration/.test(ancestors[i].type)) return
        found = true
      }
    })
    return found
  }

  function detectSourceType(code) {
    return /(^|\n)\s*(im|ex)port\b/.test(code) ? "module" : "script"
  }

  function randomID() {
    return Math.floor(Math.random() * 0xffffffff).toString(16)
  }

  function findDeps(code) {
    let deps = [], ast
    try { ast = acorn.parse(code) }
    catch(e) { return deps }
    acorn.walk.simple(ast, {
      CallExpression(node) {
        if (node.callee.type == "Identifier" && node.callee.name == "require" &&
            node.arguments.length == 1 && node.arguments[0].type == "Literal" &&
            typeof node.arguments[0].value == "string" && !deps.includes(node.arguments[0].value))
          deps.push(node.arguments[0].value)
      }
    })
    return deps
  }

  function resolveRelative(base, path) {
    if (!/\.\.?\//.test(path)) return path
    base = base.replace(/[^\/]+$/, "")
    let m
    while (m = /^\.(\.)?\//.exec(path)) {
      if (m[1]) base = base.replace(/\/[^\/]+\/$/, "/")
      path = path.slice(m[0].length)
    }
    return base + path
  }

  class Cached {
    constructor(mapping) {
      this.mapping = mapping
      this.work = Object.create(null)
      this.done = Object.create(null)
    }

    compute(value) {
      return this.work[value] || (this.work[value] = this.mapping(value).then(result => this.done[value] = result))
    }

    store(value, result) {
      this.work[value] = Promise.resolve(result)
      return this.done[value] = result
    }

    get(value) {
      return this.done[value]
    }
  }

  // Cache for loaded code and resolved unpkg redirects
  const resolved = new Cached(name => fetch("https://unpkg.com/" + name.replace(/\/$/, "")).then(resp => {
    if (resp.status >= 400) throw new Error(`Failed to resolve package '${name}'`)
    let found = resp.url.replace(/.*unpkg\.com\//, "")
    let known = resolved.get(found)
    return known || resp.text().then(code => resolved.store(found, {name: found, code}))
  }))

  SandBox.Output = class {
    constructor(div) { this.div = div }

    clear() {
      let clone = this.div.cloneNode(false)
      this.div.parentNode.replaceChild(clone, this.div)
      this.div = clone
    }

    out(type, args) {
      let wrap = document.createElement("pre")
      wrap.className = "sandbox-output-" + type
      for (let i = 0; i < args.length; ++i) {
        let arg = args[i]
        if (i) wrap.appendChild(document.createTextNode(" "))
        if (typeof arg == "string")
          wrap.appendChild(document.createTextNode(arg))
        else
          wrap.appendChild(represent(arg, 58))
      }
      this.div.appendChild(wrap)
    }
  }

  function span(type, text) {
    let sp = document.createElement("span")
    sp.className = "sandbox-output-" + type
    sp.appendChild(document.createTextNode(text))
    return sp
  }

  function eltSize(elt) {
    return elt.textContent.length
  }

  function represent(val, space) {
    if (typeof val == "boolean") return span("bool", String(val))
    if (typeof val == "number") return span("number", String(val))
    if (typeof val == "string") return span("string", JSON.stringify(val))
    if (typeof val == "symbol") return span("symbol", String(val))
    if (val == null) return span("null", String(val))
    if (Array.isArray(val)) return representArray(val, space)
    else return representObj(val, space)
  }

  function representArray(val, space) {
    space -= 2
    let wrap = document.createElement("span")
    wrap.appendChild(document.createTextNode("["))
    for (let i = 0; i < val.length; ++i) {
      if (i) {
        wrap.appendChild(document.createTextNode(", "))
        space -= 2
      }
      let next = space > 0 && represent(val[i], space)
      let nextSize = next ? eltSize(next) : 0
      if (space - nextSize <= 0) {
        wrap.appendChild(span("etc", "…")).addEventListener("click", () => expandObj(wrap, "array", val))
        break
      }
      space -= nextSize
      wrap.appendChild(next)
    }
    wrap.appendChild(document.createTextNode("]"))
    return wrap
  }

  function representObj(val, space) {
    let string = typeof val.toString == "function" && val.toString(), m
    if (!string || /^\[object .*\]$/.test(string))
      return representSimpleObj(val, space)
    if (val.call && (m = string.match(/^\s*(function[^(]*\([^)]*\))/)))
      return span("fun", m[1] + "{…}")
    let elt = span("etc", string)
    elt.addEventListener("click", () => expandObj(elt, "obj", val))
    return elt
  }

  function constructorName(obj) {
    if (!obj.constructor) return null
    let m = String(obj.constructor).match(/^function\s*([^\s(]+)/)
    if (m && m[1] != "Object") return m[1]
  }

  function hop(obj, prop) {
    return Object.prototype.hasOwnProperty.call(obj, prop)
  }

  function representSimpleObj(val, space) {
    space -= 2
    let wrap = document.createElement("span")
    let name = constructorName(val)
    if (name) {
      space -= name.length
      wrap.appendChild(document.createTextNode(name))
    }
    wrap.appendChild(document.createTextNode("{"))
    try {
      let first = true
      for (let prop in val) if (hop(val, prop)) {
        if (first) {
          first = false
        } else {
          space -= 2
          wrap.appendChild(document.createTextNode(", "))
        }
        let next = space > 0 && represent(val[prop], space)
        let nextSize = next ? prop.length + 2 + eltSize(next) : 0
        if (space - nextSize <= 0) {
          wrap.appendChild(span("etc", "…")).addEventListener("click", () => expandObj(wrap, "obj", val))
          break
        }
        space -= nextSize
        wrap.appendChild(span("prop", prop + ": "))
        wrap.appendChild(next)
      }
    } catch (e) {
      wrap.appendChild(document.createTextNode("…"))
    }
    wrap.appendChild(document.createTextNode("}"))
    return wrap
  }

  function expandObj(node, type, val) {
    let wrap = document.createElement("span")
    let opening = type == "array" ? "[" : "{", cname
    if (opening == "{" && (cname = constructorName(val))) opening = cname + " {"
    wrap.appendChild(document.createTextNode(opening))
    let block = wrap.appendChild(document.createElement("div"))
    block.className = "sandbox-output-etc-block"
    let table = block.appendChild(document.createElement("table"))
    function addProp(name) {
      let row = table.appendChild(document.createElement("tr"))
      row.appendChild(document.createElement("td")).appendChild(span("prop", name + ":"))
      row.appendChild(document.createElement("td")).appendChild(represent(val[name], 40))
    }
    if (type == "array") {
      for (let i = 0; i < val.length; ++i) addProp(i)
    } else {
      for (let prop in val) if (hop(val, prop)) addProp(prop)
    }
    wrap.appendChild(document.createTextNode(type == "array" ? "]" : "}"))
    node.parentNode.replaceChild(wrap, node)
  }

  function promiseChain(thunks) {
    function proceed(i, value) {
      if (i == thunks.length) return Promise.resolve(value)
      else return thunks[i](value).then(value => proceed(i + 1, value))
    }
    return proceed(0, null)
  }
})()