/* __ *\
** ________ ___ / / ___ __ ____ 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
}