Created
January 16, 2022 21:04
-
-
Save steinybot/b242e0934166f79d1fe9bdfe3f3372b1 to your computer and use it in GitHub Desktop.
Better Scala.js Stack Traces
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package errors | |
import scala.concurrent.{ExecutionContext, Future} | |
import scala.language.{implicitConversions, reflectiveCalls} | |
import scala.scalajs.js | |
object Errors { | |
def mapJsError( | |
message: String, | |
source: Option[String], | |
line: Option[Int], | |
column: Option[Int], | |
error: Option[js.Error] | |
)(implicit | |
ec: ExecutionContext | |
): Future[MappedThrowable] = | |
mapError(JavaScriptRuntimeError(message, source, line, column, error)) | |
def mapError(error: Throwable)(implicit ec: ExecutionContext): Future[MappedThrowable] = | |
error match { | |
case mappedError: MappedThrowable => Future.successful(mappedError) | |
case _ => | |
for { | |
stackTrace <- mapStackTrace(error.getStackTrace.toList) | |
maybeCause <- mapCause(Option(error.getCause)) | |
} yield MappedThrowable(error, stackTrace, maybeCause) | |
} | |
private def mapCause(maybeCause: Option[Throwable])(implicit ec: ExecutionContext): Future[Option[MappedThrowable]] = | |
maybeCause match { | |
case Some(cause) => mapError(cause).map(Some(_)) | |
case None => Future.successful(None) | |
} | |
private def mapStackTrace( | |
stackTrace: List[StackTraceElement] | |
)(implicit | |
ec: ExecutionContext | |
): Future[List[MappedStackTraceElement]] = | |
Future.traverse(stackTrace)(mapStackTraceElement(_)) | |
private def mapStackTraceElement( | |
element: StackTraceElement | |
)(implicit | |
ec: ExecutionContext | |
): Future[MappedStackTraceElement] = { | |
val fileName = element.getFileName | |
val line = element.getLineNumber | |
val column = element.getColumnNumber() | |
SourceMap.sourcePosition(fileName, line, column).map { position => | |
MappedStackTraceElement(element, mappedStackTraceElement(element, position)) | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package errors | |
import errors | |
import org.scalajs.dom | |
import org.scalajs.dom.ErrorEvent | |
import scala.language.reflectiveCalls | |
import scala.scalajs.js | |
final case class JavaScriptRuntimeError( | |
message: String, | |
source: Option[String], | |
line: Option[Int], | |
column: Option[Int], | |
cause: Throwable | |
) extends RuntimeException(message, cause) { | |
override def fillInStackTrace(): Throwable = { | |
setStackTrace(Array(errorEventStackTraceElement(source, line, column))) | |
this | |
} | |
} | |
object JavaScriptRuntimeError { | |
def apply( | |
message: String, | |
source: Option[String], | |
line: Option[Int], | |
column: Option[Int], | |
error: Option[js.Error] | |
): JavaScriptRuntimeError = | |
JavaScriptRuntimeError(message, source, line, column, error.map(js.JavaScriptException).orNull) | |
def apply( | |
event: ErrorEvent, | |
source: Option[String], | |
line: Option[Int], | |
column: Option[Int], | |
error: Option[js.Error] | |
): JavaScriptRuntimeError = { | |
// TODO: Determine whether this extra level of exception gives us anything. | |
val cause = | |
JavaScriptRuntimeError(event.message, Some(event.filename), Some(event.lineno), Some(event.colno), error) | |
JavaScriptRuntimeError(event.message, source, line, column, cause) | |
} | |
} | |
final case class MappedThrowable( | |
original: Throwable, | |
mappedStackTrace: List[MappedStackTraceElement], | |
cause: Option[MappedThrowable] | |
) extends RuntimeException(original.getMessage, cause.orNull) { | |
override def fillInStackTrace(): Throwable = { | |
setStackTrace(mappedStackTrace.map(_.mapped).toArray) | |
this | |
} | |
override def toString: String = original.toString | |
// Override this otherwise the default implementation will cause the console to contain multiple messages instead of | |
// a single multiline message. | |
override def printStackTrace(): Unit = | |
dom.console.error(stackTraceString) | |
def stackTraceString: String = | |
errors.getStackTrace(this) | |
} | |
final case class MappedStackTraceElement(original: StackTraceElement, mapped: StackTraceElement) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import errors.SourceMap.Position | |
import java.io.{ByteArrayOutputStream, PrintWriter} | |
import scala.language.reflectiveCalls | |
package object errors { | |
private val UnknownClassName = "Unknown" | |
private val UnknownMethodName = "unknown" | |
private val UnknownPosition = -1 | |
// TODO: Does this need the parenthesis? | |
// Not part of the API but these exist. | |
// See https://github.com/scala-js/scala-js/blob/master/javalanglib/src/main/scala/java/lang/StackTraceElement.scala. | |
//noinspection AccessorLikeMethodIsEmptyParen | |
type StackTraceElementWithColumnNumber = StackTraceElement { | |
def getColumnNumber(): Int | |
def setColumnNumber(columnNumber: Int): Unit | |
} | |
implicit def stackTraceElementWithColumnNumber(ste: StackTraceElement): StackTraceElementWithColumnNumber = | |
ste.asInstanceOf[StackTraceElementWithColumnNumber] | |
def errorEventStackTraceElement(source: Option[String], line: Option[Int], column: Option[Int]): StackTraceElement = { | |
val element = | |
new StackTraceElement( | |
UnknownClassName, | |
UnknownMethodName, | |
source.getOrElse(UnknownClassName), | |
line.getOrElse(UnknownPosition) | |
) | |
element.setColumnNumber(column.getOrElse(UnknownPosition)) | |
element | |
} | |
def mappedStackTraceElement(element: StackTraceElement, sourcePosition: Position): StackTraceElement = | |
sourcePosition.url.map(_.toString).orElse(sourcePosition.file) match { | |
case Some(urlOrFile) => | |
val lineNumber = sourcePosition.line.getOrElse(UnknownPosition) | |
val mappedElement = sourcePosition.identifier match { | |
case Some(identifier) => new StackTraceElement("<jscode>", identifier, urlOrFile, lineNumber) | |
case None => new StackTraceElement(element.getClassName, element.getMethodName, urlOrFile, lineNumber) | |
} | |
mappedElement.setColumnNumber(sourcePosition.column.getOrElse(UnknownPosition)) | |
mappedElement | |
case None => element | |
} | |
def getStackTrace(error: Throwable): String = { | |
val baos = new ByteArrayOutputStream() | |
val writer = new PrintWriter(baos) | |
error.printStackTrace(writer) | |
writer.close() | |
baos.close() | |
baos.toString | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package errors | |
import UnionImplicits._ | |
import org.scalajs.dom | |
import org.scalajs.dom.ext.Ajax | |
import typings.sourceMap.anon.Positionbiasnumberundefin | |
import typings.sourceMap.mod.{NullableMappedPosition, RawSourceMap, SourceMapConsumer, SourceMapConsumerConstructor} | |
import java.net.URI | |
import scala.concurrent.{ExecutionContext, Future} | |
import scala.scalajs.js | |
import scala.scalajs.js.Thenable.Implicits.thenable2future | |
import scala.scalajs.js.annotation.JSImport | |
import scala.util.Try | |
object SourceMap { | |
@js.native | |
@JSImport("source-map", "SourceMapConsumer") | |
object SourceMapConsumer2 extends js.Object { | |
def initialize(config: SourceMapConfig): Unit = js.native | |
} | |
trait SourceMapConfig extends js.Object { | |
var `lib/mappings.wasm`: js.UndefOr[String] = js.undefined | |
} | |
@js.native | |
@JSImport("source-map/lib/mappings.wasm", JSImport.Default) | |
object SourceMapMappings extends js.Object | |
SourceMapConsumer2.initialize(new SourceMapConfig { | |
`lib/mappings.wasm` = SourceMapMappings.asInstanceOf[String] | |
}) | |
final case class Position( | |
url: Option[URI], | |
file: Option[String], | |
identifier: Option[String], | |
line: Option[Int], | |
column: Option[Int] | |
) { | |
def positionString: Option[String] = | |
for { | |
file <- file | |
line <- line | |
column <- column | |
} yield s"$file:$line:$column" | |
} | |
object Position { | |
val Unknown: Position = Position(None, None, None, None, None) | |
def apply(codeUrl: String, position: NullableMappedPosition): Position = | |
Position( | |
resolveUrl(codeUrl, position.source.toOption), | |
position.source.toOption, | |
position.name.toOption, | |
position.line.toOption.map(_.toInt), | |
position.column.toOption.map(_.toInt) | |
) | |
private def resolveUrl(codeUrl: String, maybeSource: Option[String]): Option[URI] = | |
for { | |
url <- Try(new URI(codeUrl)).toOption | |
source <- maybeSource | |
} yield url.resolve(source) | |
} | |
// TODO: Use ConcurrentHashMap once upgraded to Scala.js 1.x | |
@volatile | |
private var sourceMaps = Map.empty[String, Future[SourceMapConsumer]] | |
def sourcePosition( | |
fileUrl: String, | |
line: Int, | |
column: Int | |
)(implicit | |
ec: ExecutionContext | |
): Future[Position] = | |
consumer(fileUrl).map { consumer => | |
val position = js.Dynamic.literal(line = line, column = column).asInstanceOf[Positionbiasnumberundefin] | |
Position(fileUrl, consumer.originalPositionFor(position)) | |
} | |
private def consumer(fileUrl: String)(implicit ec: ExecutionContext): Future[SourceMapConsumer] = | |
sourceMaps.get(fileUrl) match { | |
case Some(sourceMapConsumer) => sourceMapConsumer | |
case None => | |
sourceMaps.synchronized { | |
sourceMaps.get(fileUrl) match { | |
case Some(sourceMapConsumer) => sourceMapConsumer | |
case None => | |
val sourceMapConsumer = createConsumer(fileUrl) | |
sourceMaps += fileUrl -> sourceMapConsumer | |
sourceMapConsumer | |
} | |
} | |
} | |
private def createConsumer(fileUrl: String)(implicit ec: ExecutionContext): Future[SourceMapConsumer] = | |
Ajax.get(fileUrl).map(processCodeResponse(fileUrl, _)).flatMap { | |
case Some(sourceMapURL) => | |
val headers = Map("streaming" -> "true") | |
Ajax.get(sourceMapURL, headers = headers).flatMap(processSourceMapResponse(fileUrl, _)) | |
case None => unknownSourceMapConsumer | |
} | |
private def processCodeResponse( | |
fileUrl: String, | |
response: dom.XMLHttpRequest | |
)(implicit | |
ec: ExecutionContext | |
): Option[String] = { | |
require(response.readyState == dom.XMLHttpRequest.DONE) | |
if (response.status == 200) { | |
val code = response.responseText | |
SourceMapURL.getFrom(code).toOption | |
} else { | |
dom.console.debug(s"""Failed to retrieve source code for $fileUrl. | |
|Status ${response.status}: ${response.responseText}""".stripMargin) | |
None | |
} | |
} | |
private def processSourceMapResponse( | |
fileUrl: String, | |
response: dom.XMLHttpRequest | |
)(implicit | |
ec: ExecutionContext | |
): Future[SourceMapConsumer] = { | |
require(response.readyState == dom.XMLHttpRequest.DONE) | |
if (response.status == 200) { | |
parseSourceMap(response.responseText) | |
} else { | |
dom.console.debug(s"""Failed to retrieve source map for $fileUrl. | |
|Status ${response.status}: ${response.responseText}""".stripMargin) | |
// We use an unknown source map consumer to avoid a future lookup. | |
// We might want to have a way of busting this cache in case the source map can be found later on. | |
unknownSourceMapConsumer | |
} | |
} | |
private def parseSourceMap(text: String)(implicit ec: ExecutionContext): Future[SourceMapConsumer] = | |
Future { | |
js.JSON.parse(text).asInstanceOf[RawSourceMap] | |
}.flatMap { sourceMap => | |
(SourceMapConsumer: SourceMapConsumerConstructor).newInstance1(sourceMap) | |
}.map(_.merge[SourceMapConsumer]) | |
// TODO: Perhaps we should cache this. | |
private def unknownSourceMapConsumer(implicit ec: ExecutionContext): Future[SourceMapConsumer] = { | |
val sourceMap = js.Dynamic | |
.literal( | |
file = "unknown", | |
mappings = "", | |
names = js.Array[String](), | |
sources = js.Array[String](), | |
version = 3.0 | |
) | |
.asInstanceOf[RawSourceMap] | |
(SourceMapConsumer: SourceMapConsumerConstructor).newInstance1(sourceMap).map(_.merge[SourceMapConsumer]) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package errors | |
import scala.scalajs.js | |
import scala.scalajs.js.annotation.JSImport | |
import scala.scalajs.js.| | |
@js.native | |
@JSImport("source-map-url", JSImport.Namespace) | |
object SourceMapURL extends js.Object { | |
def getFrom(code: String): String | Null = js.native | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package errors | |
import scala.annotation.{implicitAmbiguous, implicitNotFound} | |
object TypeImplicits { | |
def unexpected: Nothing = sys.error("Unexpected invocation") | |
// Type inequalities | |
@scala.annotation.implicitNotFound("${A} must not be a ${B}") | |
trait =:!=[A, B] extends Serializable | |
implicit def neq[A, B]: A =:!= B = new =:!=[A, B] {} | |
@implicitAmbiguous("Cannot prove that ${A} =!= ${A}") | |
implicit def neqAmbig1[A]: A =:!= A = unexpected | |
implicit def neqAmbig2[A]: A =:!= A = unexpected | |
@implicitNotFound("${A} must not be a subtype of ${B}") | |
trait <:!<[A, B] extends Serializable | |
implicit def nsub[A, B]: A <:!< B = new <:!<[A, B] {} | |
@implicitAmbiguous("Cannot prove that ${A} <:!< ${B}") | |
implicit def nsubAmbig1[A, B >: A]: A <:!< B = unexpected | |
implicit def nsubAmbig2[A, B >: A]: A <:!< B = unexpected | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package errors | |
import TypeImplicits._ | |
import scala.scalajs.js | |
import scala.scalajs.js.| | |
object UnionImplicits { | |
implicit class MaybeNull[A](val maybeNull: A | Null) { | |
def toOption: Option[A] = | |
Option(maybeNull).collect { | |
case a: A => a | |
} | |
} | |
// We can only use isInstanceOf on non-js.Any types. | |
implicit class ToEitherA[A, B](aOrB: A | B)(implicit notAny: A <:!< js.Any) { | |
def toEither: Either[A, B] = | |
aOrB match { | |
case a: A => Left(a) | |
case b => Right(b.asInstanceOf[B]) | |
} | |
} | |
// We can only use isInstanceOf on non-js.Any types. | |
implicit class ToEitherB[A, B](aOrB: A | B)(implicit notAny: B <:!< js.Any) { | |
def toEither: Either[A, B] = | |
aOrB match { | |
case b: B => Right(b) | |
case a => Left(a.asInstanceOf[A]) | |
} | |
} | |
def toUnionLeft[A, B](a: A): A | B = a.asInstanceOf[A | B] | |
def toUnionRight[A, B](b: B): A | B = b.asInstanceOf[A | B] | |
implicit class ToUnion[A](val a: A) extends AnyVal { | |
def toUnionLeft[B]: A | B = UnionImplicits.toUnionLeft(a) | |
def toUnionRight[B]: B | A = UnionImplicits.toUnionRight(a) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment