Skip to content

Instantly share code, notes, and snippets.

@steinybot
Created July 11, 2022 01:23
Show Gist options
  • Save steinybot/9e27adc959ba53f3f3475e82807ef05c to your computer and use it in GitHub Desktop.
Save steinybot/9e27adc959ba53f3f3475e82807ef05c to your computer and use it in GitHub Desktop.
Scala.js - Nice source maps for exceptions
    // TODO: Determine whether we need traceKitWindowOnError to map the errors too.
    dom.window.asInstanceOf[std.Window].onerror = toUnionLeft(onErrorEventHandler _)
  private def onErrorEventHandler(
    event: dom.Event | String,
    source: js.UndefOr[String],
    lineno: js.UndefOr[Double],
    colno: js.UndefOr[Double],
    error: js.UndefOr[js.Error]
  ): js.Any = {
    val message = event.toEither.left.map { event =>
      // Event though the API says that it can be any Event, the window.onerror will only ever be an ErrorEvent.
      // See https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers/onerror.
      val errorEvent = event.asInstanceOf[ErrorEvent]
      // The type of ErrorEvent.message is wrong. It can be undefined.
      if (!js.isUndefined(errorEvent.message)) errorEvent.message else "Unknown error."
    }.merge
    MessageBox.error(s"""An unexpected error occurred:
                        |$message""".stripMargin)
    val jsError = JavaScriptRuntimeError(
      message,
      source.toOption,
      lineno.toOption.map(_.toInt),
      colno.toOption.map(_.toInt),
      error.toOption
    )
    logger.error("An unexpected error occurred.", jsError)
    // Prevent the default handler from firing.
    true
  }
package com.example.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))
}
}
}
package com.example.errors
import com.example.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)
package com.example
import com.example.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
}
}
package com.example.errors
import com.example.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])
}
}
package com.example.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
}
package com.example
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
}
package com.example
import com.example.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