cli.scala

Our test cli program does not do much, it merely prints out the arguments. It uses nodejs modules chalk and table to make the output fancy. We use chalk and table as two npm dependencies to illustrate how to manage and bundle dependencies. The cli application also depends on scopt, a popular scala command line parsing tool.

We also choose to create some facades for chalk and table directly in our .scala to show the FFI aspect of interfacing with the javascript modules in the same cli app. We show 3 different ways of using FFI which require webpack to resolve correctly.

The scala app is straight forward:

package app

import scala.scalajs.js
import js.{JSApp,|}
import js.Dynamic.{literal => lit}
import js.{Array => arr}
import js.annotation._
import scopt._
import io.scalajs.nodejs._

object Main extends JSApp {

  case class AppConfig(
    a: Option[String] = None,
    b: Option[Int] = None)

  val parser = new scopt.OptionParser[AppConfig]("cli-app") {
    override def terminate(exitState: Either[String, Unit]): Unit = {
      process.exit()
    }

    opt[String]('a', "a-arg").valueName("<arg>")
      .text("Argument a")
      .action((x,c) => c.copy(a = Option(x)))

    opt[Int]('b', "b-arg").valueName("<int>")
      .text("Argument b")
      .action((x,c)=> c.copy(b = Option(x)))

    help("help").text("cli application")
  }

  def main(): Unit = {
    val config: AppConfig =  parser.parse(process.argv.drop(2), AppConfig()) match {
      case Some(c) => c // Ok!
      case _ => process.exit(-1); return // return keeps type checker happy
    }

    import ContentFormatting._
    val notProvided = notProvidedChalk(Messages.notProvidedMessage)
    val output = Table.table(
      arr(
        arr(Chalk.bold("Parameter"), Chalk.bold("Value")),
        arr("a", config.a.map(hasValue(_)).getOrElse(notProvided)),
        arr("b", config.b.map(i => hasValue(i.toString)).getOrElse(notProvided))),
      new TableOptions(
        border = Table.getBorderCharacters("ramac")
      ))

    println("Program arguments:")
    println(output)
  }

  //
  // FFI to JS libraries that do not have or we are not using a scala facade.
  //

  @js.native
  @JSImport("table", JSImport.Namespace)
  object Table extends js.Object {
    def table(data: js.Array[js.Array[Any]], options: TableOptions): String = js.native
    def getBorderCharacters(templateName: String): js.Dictionary[String] = js.native
  }

  @ScalaJSDefined
  class TableOptions(
    val columns: js.UndefOr[js.Dynamic] = js.undefined,
    val columnDefault: js.UndefOr[js.Dynamic] = js.undefined,
    val border: js.UndefOr[String|js.Dictionary[String]] = js.undefined,
    val drawJoin: js.UndefOr[js.Function2[Int, Int, Unit]] = js.undefined
    // ...
  ) extends js.Object

  @ScalaJSDefined
  class Column( 
    alignment: js.UndefOr[String] = js.undefined,
    width: js.UndefOr[Int] = js.undefined,
    truncate: js.UndefOr[Int] = js.undefined,
    paddingLeft: js.UndefOr[Int] = js.undefined,
    paddingRight: js.UndefOr[Int] = js.undefined,
    wrapWord: js.UndefOr[Boolean] = js.undefined
  ) extends js.Object

  @js.native
  @JSImport("chalk", JSImport.Namespace)
  object Chalk extends js.Object {
    val supportsColor: Boolean = js.native
    def enabled: Boolean = js.native
    def enabled_=(v: Boolean): Unit = js.native
    def bold: js.Dynamic = js.native
    def bgRed: js.Dynamic  = js.native
    def white: js.Dynamic = js.native
    def green: js.Dynamic = js.native
  }

  /** 
    * Colorize a string to alert user that a command line arg was not provided.
    * This is an one-off function for formatting. Provided is replaced with 
    * another path specified in the webpack config.
    */
  @js.native
  @JSImport("Provided/formatting.js", JSImport.Default)
  object notProvidedChalk extends js.Object {
    def apply(arg: String): String = js.native
  }

  /** A set of messages to use.
    * This shows js files next to scala files.
    */
  @js.native
  @JSImport("./messages.js", JSImport.Namespace)
  object Messages extends js.Object {
    val notProvidedMessage: String = js.native
  }


  /**
    * This shows "module "style importing as if it
    * was a node.js module. No relative path information
    * in the "import".
    */
  @js.native
  @JSImport("ContentFormatting", JSImport.Namespace)
  object ContentFormatting extends js.Object {
    def hasValue(arg: String): String = js.native
  }

}

Our cli application js dependencies are equally straight forward:

formatting.js

let Chalk = require("chalk")

function backgroundRed(arg) {
    return Chalk.white.bgRed(arg)
}

exports.default = backgroundRed

messages.js

// No other dependences for this module

exports.notProvidedMessage = "not provided"

ContentFormating.js

let Chalk = require("chalk")

export function hasValue(value) {
    return Chalk.green(value);
}

scalajs compilation

When the above is compiled with scalaJSModuleKind set to CommonJSModule, scalajs places "module" resolution calls using "require" into the output file. If we 'cd' down through "target/scala-2.12" (assuming the standard output location) we can see the output files and find the "requires:"

[scala-2.12]$ grep 'require(' bundled1-fastopt.js
var $i_chalk = require("chalk");
var $i_$002e$002fcli$002fkeepunbundled$002ejs = require("./cli/messages.js");
var $i_table = require("table");
var $i_$002e$002fcli$002fexternal$002ejs = require("./cli/messages.js");
var $i_os = require("os");
...

In the output we see "requires" from our @JSImport annotations. In both cases, the chalk dependency was not present on the "classpath" and hence, there was no way sbt-scalajs could know "how" to pull in "chalk" javascript files into the main scalajs outputs and they remained "require" calls. Alot of what we will cover is how to resolve the require("chalk") and the other "requires" at runtime and testing.

babel

Our babel config reflects the need to transpile standard javascript from some higher level down to node.js level. You do not really need to do this if the application javascript you write does not require it, but we will add it in order to make a point later. Since babel creates sources maps, based on this config, we do not want to run babel on the scalajs output, which already produces source maps.

{
    presets: ['env'],
    sourceMaps: "inline",
    plugins: ["transform-object-rest-spread"]
}

Last updated