diff options
Diffstat (limited to 'examples/scala-js/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs')
5 files changed, 675 insertions, 0 deletions
diff --git a/examples/scala-js/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/JettyWebsocketManager.scala b/examples/scala-js/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/JettyWebsocketManager.scala new file mode 100644 index 0000000..3dec79c --- /dev/null +++ b/examples/scala-js/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/JettyWebsocketManager.scala @@ -0,0 +1,126 @@ +package scala.scalajs.sbtplugin.env.phantomjs + +import javax.servlet.http.HttpServletRequest + +import org.eclipse.jetty.server.Server +import org.eclipse.jetty.server.nio.SelectChannelConnector +import org.eclipse.jetty.websocket.{WebSocket, WebSocketHandler} +import org.eclipse.jetty.util.component.{LifeCycle, AbstractLifeCycle} +import org.eclipse.jetty.util.log + +private[phantomjs] final class JettyWebsocketManager( + wsListener: WebsocketListener) extends WebsocketManager { thisMgr => + + private[this] var webSocketConn: WebSocket.Connection = null + private[this] var closed = false + + // We can just set the logger here, since we are supposed to be protected by + // the private ClassLoader that loads us reflectively. + log.Log.setLog(new WSLogger("root")) + + private[this] val connector = new SelectChannelConnector + + connector.setHost("localhost") + connector.setPort(0) + + private[this] val server = new Server() + + server.addConnector(connector) + server.setHandler(new WebSocketHandler { + // Support Hixie 76 for Phantom.js + getWebSocketFactory().setMinVersion(-1) + + override def doWebSocketConnect( + request: HttpServletRequest, protocol: String): WebSocket = + new ComWebSocketListener + }) + + server.addLifeCycleListener(new AbstractLifeCycle.AbstractLifeCycleListener { + override def lifeCycleStarted(event: LifeCycle): Unit = { + if (event.isRunning()) + wsListener.onRunning() + } + }) + + private class ComWebSocketListener extends WebSocket.OnTextMessage { + override def onOpen(connection: WebSocket.Connection): Unit = { + thisMgr.synchronized { + if (isConnected) + throw new IllegalStateException("Client connected twice") + webSocketConn = connection + } + wsListener.onOpen() + } + + override def onClose(statusCode: Int, reason: String): Unit = { + thisMgr.synchronized { + webSocketConn = null + closed = true + } + wsListener.onClose() + server.stop() + + if (statusCode != 1000) { + throw new Exception("Abnormal closing of connection. " + + s"Code: $statusCode, Reason: $reason") + } + } + + override def onMessage(message: String): Unit = + wsListener.onMessage(message) + } + + private class WSLogger(fullName: String) extends log.AbstractLogger { + private[this] var debugEnabled = false + + def debug(msg: String, args: Object*): Unit = + if (debugEnabled) log("DEBUG", msg, args) + + def debug(msg: String, thrown: Throwable): Unit = + if (debugEnabled) log("DEBUG", msg, thrown) + + def debug(thrown: Throwable): Unit = + if (debugEnabled) log("DEBUG", thrown) + + def getName(): String = fullName + + def ignore(ignored: Throwable): Unit = () + + def info(msg: String, args: Object*): Unit = log("INFO", msg, args) + def info(msg: String, thrown: Throwable): Unit = log("INFO", msg, thrown) + def info(thrown: Throwable): Unit = log("INFO", thrown) + + def warn(msg: String, args: Object*): Unit = log("WARN", msg, args) + def warn(msg: String, thrown: Throwable): Unit = log("WARN", msg, thrown) + def warn(thrown: Throwable): Unit = log("WARN", thrown) + + def isDebugEnabled(): Boolean = debugEnabled + def setDebugEnabled(enabled: Boolean): Unit = debugEnabled = enabled + + private def log(lvl: String, msg: String, args: Object*): Unit = + wsListener.log(s"$lvl: $msg " + args.mkString(", ")) + + private def log(lvl: String, msg: String, thrown: Throwable): Unit = + wsListener.log(s"$lvl: $msg $thrown\n{$thrown.getStackStrace}") + + private def log(lvl: String, thrown: Throwable): Unit = + wsListener.log(s"$lvl: $thrown\n{$thrown.getStackStrace}") + + protected def newLogger(fullName: String) = new WSLogger(fullName) + } + + def start(): Unit = server.start() + + def stop(): Unit = server.stop() + + def isConnected: Boolean = webSocketConn != null && !closed + def isClosed: Boolean = closed + + def localPort: Int = connector.getLocalPort() + + def sendMessage(msg: String) = synchronized { + if (webSocketConn != null) + webSocketConn.sendMessage(msg) + } + +} diff --git a/examples/scala-js/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/PhantomJSEnv.scala b/examples/scala-js/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/PhantomJSEnv.scala new file mode 100644 index 0000000..7bb47d2 --- /dev/null +++ b/examples/scala-js/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/PhantomJSEnv.scala @@ -0,0 +1,466 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin.env.phantomjs + +import scala.scalajs.sbtplugin.env._ + +import scala.scalajs.tools.io._ +import scala.scalajs.tools.classpath._ +import scala.scalajs.tools.env._ +import scala.scalajs.tools.logging._ + +import scala.scalajs.sbtplugin.JSUtils._ + +import java.io.{ Console => _, _ } +import java.net._ + +import scala.io.Source +import scala.collection.mutable +import scala.annotation.tailrec + +class PhantomJSEnv( + phantomjsPath: String = "phantomjs", + addArgs: Seq[String] = Seq.empty, + addEnv: Map[String, String] = Map.empty, + val autoExit: Boolean = true, + jettyClassLoader: ClassLoader = getClass().getClassLoader() +) extends ExternalJSEnv(addArgs, addEnv) with ComJSEnv { + + import PhantomJSEnv._ + + protected def vmName: String = "PhantomJS" + protected def executable: String = phantomjsPath + + override def jsRunner(classpath: CompleteClasspath, code: VirtualJSFile, + logger: Logger, console: JSConsole): JSRunner = { + new PhantomRunner(classpath, code, logger, console) + } + + override def asyncRunner(classpath: CompleteClasspath, code: VirtualJSFile, + logger: Logger, console: JSConsole): AsyncJSRunner = { + new AsyncPhantomRunner(classpath, code, logger, console) + } + + override def comRunner(classpath: CompleteClasspath, code: VirtualJSFile, + logger: Logger, console: JSConsole): ComJSRunner = { + new ComPhantomRunner(classpath, code, logger, console) + } + + protected class PhantomRunner(classpath: CompleteClasspath, + code: VirtualJSFile, logger: Logger, console: JSConsole + ) extends ExtRunner(classpath, code, logger, console) + with AbstractPhantomRunner + + protected class AsyncPhantomRunner(classpath: CompleteClasspath, + code: VirtualJSFile, logger: Logger, console: JSConsole + ) extends AsyncExtRunner(classpath, code, logger, console) + with AbstractPhantomRunner + + protected class ComPhantomRunner(classpath: CompleteClasspath, + code: VirtualJSFile, logger: Logger, console: JSConsole + ) extends AsyncPhantomRunner(classpath, code, logger, console) + with ComJSRunner with WebsocketListener { + + private def loadMgr() = { + val clazz = jettyClassLoader.loadClass( + "scala.scalajs.sbtplugin.env.phantomjs.JettyWebsocketManager") + + val ctors = clazz.getConstructors() + assert(ctors.length == 1, "JettyWebsocketManager may only have one ctor") + + val mgr = ctors.head.newInstance(this) + + mgr.asInstanceOf[WebsocketManager] + } + + val mgr: WebsocketManager = loadMgr() + + def onRunning(): Unit = synchronized(notifyAll()) + def onOpen(): Unit = synchronized(notifyAll()) + def onClose(): Unit = synchronized(notifyAll()) + + def onMessage(msg: String): Unit = synchronized { + recvBuf.enqueue(msg) + notifyAll() + } + + def log(msg: String): Unit = logger.debug(s"PhantomJS WS Jetty: $msg") + + private[this] val recvBuf = mutable.Queue.empty[String] + + mgr.start() + + /** The websocket server starts asynchronously, but we need the port it is + * running on. This method waits until the port is non-negative and + * returns its value. + */ + private def waitForPort(): Int = { + while (mgr.localPort < 0) + wait() + mgr.localPort + } + + private def comSetup = { + def maybeExit(code: Int) = + if (autoExit) + s"window.callPhantom({ action: 'exit', returnValue: $code });" + else + "" + + val serverPort = waitForPort() + + val code = s""" + |(function() { + | var MaxPayloadSize = $MaxCharPayloadSize; + | + | // The socket for communication + | var websocket = null; + | + | // Buffer for messages sent before socket is open + | var outMsgBuf = null; + | + | function sendImpl(msg) { + | var frags = (msg.length / MaxPayloadSize) | 0; + | + | for (var i = 0; i < frags; ++i) { + | var payload = msg.substring( + | i * MaxPayloadSize, (i + 1) * MaxPayloadSize); + | websocket.send("1" + payload); + | } + | + | websocket.send("0" + msg.substring(frags * MaxPayloadSize)); + | } + | + | function recvImpl(recvCB) { + | var recvBuf = ""; + | + | return function(evt) { + | var newData = recvBuf + evt.data.substring(1); + | if (evt.data.charAt(0) == "0") { + | recvBuf = ""; + | recvCB(newData); + | } else if (evt.data.charAt(0) == "1") { + | recvBuf = newData; + | } else { + | throw new Error("Bad fragmentation flag in " + evt.data); + | } + | }; + | } + | + | window.scalajsCom = { + | init: function(recvCB) { + | if (websocket !== null) throw new Error("Com already open"); + | + | outMsgBuf = []; + | + | websocket = new WebSocket("ws://localhost:$serverPort"); + | + | websocket.onopen = function(evt) { + | for (var i = 0; i < outMsgBuf.length; ++i) + | sendImpl(outMsgBuf[i]); + | outMsgBuf = null; + | }; + | websocket.onclose = function(evt) { + | websocket = null; + | ${maybeExit(0)} + | }; + | websocket.onmessage = recvImpl(recvCB); + | websocket.onerror = function(evt) { + | websocket = null; + | throw new Error("Websocket failed: " + evt); + | }; + | + | // Take over responsibility to auto exit + | window.callPhantom({ + | action: 'setAutoExit', + | autoExit: false + | }); + | }, + | send: function(msg) { + | if (websocket === null) + | return; // we are closed already. ignore message + | + | if (outMsgBuf !== null) + | outMsgBuf.push(msg); + | else + | sendImpl(msg); + | }, + | close: function() { + | if (websocket === null) + | return; // we are closed already. all is well. + | + | if (outMsgBuf !== null) + | // Reschedule ourselves to give onopen a chance to kick in + | window.setTimeout(window.scalajsCom.close, 10); + | else + | websocket.close(); + | } + | } + |}).call(this);""".stripMargin + + new MemVirtualJSFile("comSetup.js").withContent(code) + } + + def send(msg: String): Unit = synchronized { + if (awaitConnection()) { + val fragParts = msg.length / MaxCharPayloadSize + + for (i <- 0 until fragParts) { + val payload = msg.substring( + i * MaxCharPayloadSize, (i + 1) * MaxCharPayloadSize) + mgr.sendMessage("1" + payload) + } + + mgr.sendMessage("0" + msg.substring(fragParts * MaxCharPayloadSize)) + } + } + + def receive(): String = synchronized { + if (recvBuf.isEmpty && !awaitConnection()) + throw new ComJSEnv.ComClosedException + + @tailrec + def loop(acc: String): String = { + val frag = receiveFrag() + val newAcc = acc + frag.substring(1) + + if (frag(0) == '0') + newAcc + else if (frag(0) == '1') + loop(newAcc) + else + throw new AssertionError("Bad fragmentation flag in " + frag) + } + + loop("") + } + + private def receiveFrag(): String = { + while (recvBuf.isEmpty && !mgr.isClosed) + wait() + + if (recvBuf.isEmpty) + throw new ComJSEnv.ComClosedException + else + recvBuf.dequeue() + } + + def close(): Unit = mgr.stop() + + override def stop(): Unit = { + close() + super.stop() + } + + /** Waits until the JS VM has established a connection, or the VM + * terminated. Returns true if a connection was established. + */ + private def awaitConnection(): Boolean = { + while (!mgr.isConnected && !mgr.isClosed && isRunning) + wait(200) // We sleep-wait for isRunning + + mgr.isConnected + } + + override protected def initFiles(): Seq[VirtualJSFile] = + super.initFiles :+ comSetup + } + + protected trait AbstractPhantomRunner extends AbstractExtRunner { + + override protected def getVMArgs() = + // Add launcher file to arguments + additionalArgs :+ createTmpLauncherFile().getAbsolutePath + + /** In phantom.js, we include JS using HTML */ + override protected def writeJSFile(file: VirtualJSFile, writer: Writer) = { + file match { + case file: FileVirtualJSFile => + val fname = htmlEscape(file.file.getAbsolutePath) + writer.write( + s"""<script type="text/javascript" src="$fname"></script>""" + "\n") + case _ => + writer.write("""<script type="text/javascript">""" + "\n") + writer.write(s"// Virtual File: ${file.path}\n") + writer.write(file.content) + writer.write("</script>\n") + } + } + + /** + * PhantomJS doesn't support Function.prototype.bind. We polyfill it. + * https://github.com/ariya/phantomjs/issues/10522 + */ + override protected def initFiles(): Seq[VirtualJSFile] = Seq( + new MemVirtualJSFile("bindPolyfill.js").withContent( + """ + |// Polyfill for Function.bind from Facebook react: + |// https://github.com/facebook/react/blob/3dc10749080a460e48bee46d769763ec7191ac76/src/test/phantomjs-shims.js + |// Originally licensed under Apache 2.0 + |(function() { + | + | var Ap = Array.prototype; + | var slice = Ap.slice; + | var Fp = Function.prototype; + | + | if (!Fp.bind) { + | // PhantomJS doesn't support Function.prototype.bind natively, so + | // polyfill it whenever this module is required. + | Fp.bind = function(context) { + | var func = this; + | var args = slice.call(arguments, 1); + | + | function bound() { + | var invokedAsConstructor = func.prototype && (this instanceof func); + | return func.apply( + | // Ignore the context parameter when invoking the bound function + | // as a constructor. Note that this includes not only constructor + | // invocations using the new keyword but also calls to base class + | // constructors such as BaseClass.call(this, ...) or super(...). + | !invokedAsConstructor && context || this, + | args.concat(slice.call(arguments)) + | ); + | } + | + | // The bound function must share the .prototype of the unbound + | // function so that any object created by one constructor will count + | // as an instance of both constructors. + | bound.prototype = func.prototype; + | + | return bound; + | }; + | } + | + |})(); + |""".stripMargin + ), + new MemVirtualJSFile("scalaJSEnvInfo.js").withContent( + """ + |__ScalaJSEnv = { + | exitFunction: function(status) { + | window.callPhantom({ + | action: 'exit', + | returnValue: status | 0 + | }); + | } + |}; + """.stripMargin + ) + ) + + protected def writeWebpageLauncher(out: Writer): Unit = { + out.write("<html>\n<head>\n<title>Phantom.js Launcher</title>\n") + sendJS(getLibJSFiles(), out) + writeCodeLauncher(code, out) + out.write("</head>\n<body></body>\n</html>\n") + } + + protected def createTmpLauncherFile(): File = { + val webF = createTmpWebpage() + + val launcherTmpF = File.createTempFile("phantomjs-launcher", ".js") + launcherTmpF.deleteOnExit() + + val out = new FileWriter(launcherTmpF) + + try { + out.write( + s"""// Scala.js Phantom.js launcher + |var page = require('webpage').create(); + |var url = ${toJSstr(webF.getAbsolutePath)}; + |var autoExit = $autoExit; + |page.onConsoleMessage = function(msg) { + | console.log(msg); + |}; + |page.onError = function(msg, trace) { + | console.error(msg); + | if (trace && trace.length) { + | console.error(''); + | trace.forEach(function(t) { + | console.error(' ' + t.file + ':' + t.line + (t.function ? ' (in function "' + t.function +'")' : '')); + | }); + | } + | + | phantom.exit(2); + |}; + |page.onCallback = function(data) { + | if (!data.action) { + | console.error('Called callback without action'); + | phantom.exit(3); + | } else if (data.action === 'exit') { + | phantom.exit(data.returnValue || 0); + | } else if (data.action === 'setAutoExit') { + | if (typeof(data.autoExit) === 'boolean') + | autoExit = data.autoExit; + | else + | autoExit = true; + | } else { + | console.error('Unknown callback action ' + data.action); + | phantom.exit(4); + | } + |}; + |page.open(url, function (status) { + | if (autoExit || status !== 'success') + | phantom.exit(status !== 'success'); + |}); + |""".stripMargin) + } finally { + out.close() + } + + logger.debug( + "PhantomJS using launcher at: " + launcherTmpF.getAbsolutePath()) + + launcherTmpF + } + + protected def createTmpWebpage(): File = { + val webTmpF = File.createTempFile("phantomjs-launcher-webpage", ".html") + webTmpF.deleteOnExit() + + val out = new BufferedWriter(new FileWriter(webTmpF)) + try { + writeWebpageLauncher(out) + } finally { + out.close() + } + + logger.debug( + "PhantomJS using webpage launcher at: " + webTmpF.getAbsolutePath()) + + webTmpF + } + + protected def writeCodeLauncher(code: VirtualJSFile, out: Writer): Unit = { + out.write("""<script type="text/javascript">""" + "\n") + out.write("// Phantom.js code launcher\n") + out.write(s"// Origin: ${code.path}\n") + out.write("window.addEventListener('load', function() {\n") + out.write(code.content) + out.write("}, false);\n") + out.write("</script>\n") + } + } + + protected def htmlEscape(str: String): String = str.flatMap { + case '<' => "<" + case '>' => ">" + case '"' => """ + case '&' => "&" + case c => c :: Nil + } + +} + +object PhantomJSEnv { + private final val MaxByteMessageSize = 32768 // 32 KB + private final val MaxCharMessageSize = MaxByteMessageSize / 2 // 2B per char + private final val MaxCharPayloadSize = MaxCharMessageSize - 1 // frag flag +} diff --git a/examples/scala-js/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/PhantomJettyClassLoader.scala b/examples/scala-js/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/PhantomJettyClassLoader.scala new file mode 100644 index 0000000..02c229b --- /dev/null +++ b/examples/scala-js/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/PhantomJettyClassLoader.scala @@ -0,0 +1,63 @@ +package scala.scalajs.sbtplugin.env.phantomjs + +import scala.scalajs.tools.io.IO + +/** A special [[ClassLoader]] to load the Jetty 8 dependency of [[PhantomJSEnv]] + * in a private space. + * + * It loads everything that belongs to [[JettyWebsocketManager]] itself (while + * retrieving the requested class file from its parent. + * For all other classes, it first tries to load them from [[jettyLoader]], + * which should only contain the Jetty 8 classpath. + * If this fails, it delegates to its parent. + * + * The rationale is, that [[JettyWebsocketManager]] and its dependees can use + * the classes on the Jetty 8 classpath, while they remain hidden from the rest + * of the Java world. This allows to load another version of Jetty in the same + * JVM for the rest of the project. + */ +private[sbtplugin] class PhantomJettyClassLoader(jettyLoader: ClassLoader, + parent: ClassLoader) extends ClassLoader(parent) { + + def this(loader: ClassLoader) = + this(loader, ClassLoader.getSystemClassLoader()) + + /** Classes needed to bridge private jetty classpath and public PhantomJS + * Basically everything defined in JettyWebsocketManager. + */ + private val bridgeClasses = Set( + "scala.scalajs.sbtplugin.env.phantomjs.JettyWebsocketManager", + "scala.scalajs.sbtplugin.env.phantomjs.JettyWebsocketManager$WSLogger", + "scala.scalajs.sbtplugin.env.phantomjs.JettyWebsocketManager$ComWebSocketListener", + "scala.scalajs.sbtplugin.env.phantomjs.JettyWebsocketManager$$anon$1", + "scala.scalajs.sbtplugin.env.phantomjs.JettyWebsocketManager$$anon$2" + ) + + override protected def loadClass(name: String, resolve: Boolean): Class[_] = { + if (bridgeClasses.contains(name)) { + // Load bridgeClasses manually since they must be associated to this + // class loader, rather than the parent class loader in order to find the + // jetty classes + + // First check if we have loaded it already + Option(findLoadedClass(name)) getOrElse { + val wsManager = + parent.getResourceAsStream(name.replace('.', '/') + ".class") + + if (wsManager == null) { + throw new ClassNotFoundException(name) + } else { + val buf = IO.readInputStreamToByteArray(wsManager) + defineClass(name, buf, 0, buf.length) + } + } + } else { + try { + jettyLoader.loadClass(name) + } catch { + case _: ClassNotFoundException => + super.loadClass(name, resolve) + } + } + } +} diff --git a/examples/scala-js/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/WebsocketListener.scala b/examples/scala-js/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/WebsocketListener.scala new file mode 100644 index 0000000..4faac64 --- /dev/null +++ b/examples/scala-js/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/WebsocketListener.scala @@ -0,0 +1,10 @@ +package scala.scalajs.sbtplugin.env.phantomjs + +private[phantomjs] trait WebsocketListener { + def onRunning(): Unit + def onOpen(): Unit + def onClose(): Unit + def onMessage(msg: String): Unit + + def log(msg: String): Unit +} diff --git a/examples/scala-js/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/WebsocketManager.scala b/examples/scala-js/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/WebsocketManager.scala new file mode 100644 index 0000000..a466841 --- /dev/null +++ b/examples/scala-js/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/WebsocketManager.scala @@ -0,0 +1,10 @@ +package scala.scalajs.sbtplugin.env.phantomjs + +private[phantomjs] trait WebsocketManager { + def start(): Unit + def stop(): Unit + def sendMessage(msg: String): Unit + def localPort: Int + def isConnected: Boolean + def isClosed: Boolean +} |