Search code examples
scala.jsscalajs-bundler

How to use JSImport when writing scalajs facade for javascript modules


I have written a facade using JSImport, and it works. Unfortunately, I arrived at the solution through trial and error, and I don't fully understand why this particular solution works but others I tried did not.

Background: I'm starting with a working project, built with sbt, which is a single page application that implements the client side code with scala.js and the server side with scala and the Play framework. The javascript libraries were packaged with web jars and bundled into the client js file using the sbt jsDependencies variable. I wanted to implement some new features which required a library up rev, which then required an up rev of some javascript libs which were only available in npm format. So now I am including all the javascript dependencies for the client app using npmDependencies with the scalajs-bundler plugin. This broke some of the scalajs facades leading to my question.

I'll use the facade to log4javascript as an example for this question.

The variable log4javascript is the top level object used to access the rest of the api.

When the js libs were included as web jars, this is how the facade to log4javascript was implemented:

@js.native
@js.annotation.JSGlobalScope
object Log4JavaScript extends js.Object {
  val log4javascript:Log4JavaScript = js.native
}

After the change to npm:


import scala.scalajs.js.annotation.JSImport.Namespace

@JSImport("log4javascript", Namespace)
@js.native
object Log4JavaScript extends js.Object {
  def resetConfiguration(): Unit = js.native
  def getLogger(name:js.UndefOr[String]): JSLogger = js.native
  ...
}

Following the scala.js docs for writing importing modules I expected the object name (Log4JavaScript in this case) would have to match the exported symbol name in order for the binding to work. However, the top level symbol in log4javascript.js is log4javascript. After experimenting, it seems the scala object name makes no difference for the binding. It binds correctly no matter what I name the scala top level object.

Can someone explain what relationship exists, if any, between the scala object/class/def/val names and the names in the javascript module when using the 'Namespace' arg to JSImport?

According to the scala.js docs, it seems I should be able to provide the actual name of the js object (I also tried "Log4JavaScript")

@JSImport("log4javascript", "log4javascript")
@js.native
object SomeOtherName extends js.Object {
  def resetConfiguration(): Unit = js.native
  def getLogger(name:js.UndefOr[String]): JSLogger = js.native
  ...
}

However, this fails to bind. I will get a runtime error when I try to access any of the member functions.

Log4JavaScript.resetConfiguration()

Uncaught TypeError: Cannot read property 'resetConfiguration' of undefined

Can someone explain why this doesn't work?

log4javascript also defines some classes inside the scope of log4javascript. When the lib was included as a web jar the definition looked like:

@js.native
@JSGlobal("log4javascript.AjaxAppender")
class AjaxAppender(url:String) extends Appender {
  def addHeader(header:String, value:String):Unit = js.native
}

After switching to npm I had to put the class definition inside the top level object:

@js.native
trait Appender extends js.Object {
  ...
}

@JSImport("log4javascript", "log4javascript")
@js.native
object Log4JavaScript extends js.Object {
  ...
  class AjaxAppender(url: String) extends Appender {
    def addHeader(name: String, value: String): Unit = js.native 
  }
  ...
}

This seems sensible, but from the scala.js docs it seems like it should have been possible to define it this way outside of the top level object

@JSImport("log4javascript", "log4javascript.AjaxAppender")
@js.native
class AjaxAppender(url: String) extends Appender {
  def addHeader(name: String, value: String): Unit = js.native 
}

However, this also fails to bind. Could someone explain the correct way to define the class as above? Or is the definition nested inside the Log4JavaScript object the only correct way to do it?


Solution

  • Can someone explain what relationship exists, if any, between the scala object/class/def/val names and the names in the javascript module when using the 'Namespace' arg to JSImport?

    This is explained in this part of the Scala.js documentation. The name of the Scala object defining the facade does not matter. What matter are the parameters of the @JSImport annotation. The first one indicates which module to import from, and the second one indicates what to import.

    In your case, the log4javascript module is in the log4javascript.js file, in the log4javascript package directory. So, your first parameter should be:

    @JSImport("log4javascript/log4javascript.js", ...)
    object Log4JavaScript ...
    

    However, log4javascript is defined as an npm module whose main file refers to the log4javascript.js file. This means that you can just use the package directory name:

    @JSImport("log4javascript", ...)
    object Log4JavaScript ...
    

    (See this article for more information on how NodeJS does resolve modules)

    The second parameter of the @JSImport annotation indicates what to import. In your case, you want to import the whole module, not just a member of it, so you want to use Namespace:

    @JSImport("log4javascript", Namespace)
    object Log4JavaScript ...
    

    This corresponds to the following EcmaScript import statement:

    import * as Log4JavaScript from 'log4javascript'
    

    Note that, although the Scala object name (Log4JavaScript, here) does not matter, the names of its members does matter, as explained in this part of the Scala.js documentation.

    According to the scala.js docs, it seems I should be able to provide the actual name of the js object (I also tried "Log4JavaScript")

    @JSImport("log4javascript", "log4javascript")
    ...
    

    However, this fails to bind. I will get a runtime error when I try to access any of the member functions.

    When you write that, you try to access to the log4javascript member of the log4javascript module. But that module does not have such a member.

    it should have been possible to define it this way outside of the top level object

    @JSImport("log4javascript", "log4javascript.AjaxAppender")
    ...
    

    However, this also fails to bind.

    Again, this means “import the log4javascript.AjaxAppender member from the log4javascript module”, but that module does not have such a member. The following should work:

    @JSImport("log4javascript", "AjaxAppender")