Skip to content
This repository has been archived by the owner on Jul 12, 2024. It is now read-only.

Actually use our linker as the linker for sample/fastLinkJS. #47

Merged
merged 10 commits into from
Apr 1, 2024
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ jobs:
- name: Run the Sample
run: sbt sample/run
- name: Format
run: sbt scalafmtCheckAll
run: sbt scalafmtCheckAll
63 changes: 42 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,61 @@

[![CI](https://github.com/tanishiking/scala-wasm/actions/workflows/ci.yml/badge.svg)](https://github.com/tanishiking/scala-wasm/actions/workflows/ci.yml)

### Usage
### Prerequisites

- first, `npm install`
- then `sample/run` to compile `sample/src/main/scala/Sample.scala`.
- prints the WebAssembly Text Format (WAT) (Stack IR form) to the console, for debugging
- writes the binary format (WASM) to `target/output.wasm`
This project requires Node.js >= 22, which is available as nightly builds as of this writing.
This is necessary to get enough support of WasmGC.

Run the binary using through `run.js` using a JavaScript engine that supports WasmGC, such as Deno
If you are using NVM, you can instal Node.js 22 as follows:

```sh
$ deno run --allow-read run.mjs
# Enable resolution of nightly builds
$ NVM_NODEJS_ORG_MIRROR=https://nodejs.org/download/nightly nvm install v22
# Switch to Node.js 22
$ nvm use 22
```

### Debugging tools
Otherwise, you can [manually download nightly builds of Node.js](https://nodejs.org/download/nightly/).

- The WasmGC reference interpreter can be used to validate and convert between the binary and text form:
- https://github.com/WebAssembly/gc/tree/main/interpreter
- Use docker image for it https://github.com/tanishiking/wasmgc-docker
### Setup

### Testing
Before doing anything else, run `npm install`.

Requires NodeJS >= 22 (for enough support of WasmGC).
### Run the sample

```sh
# if you are using NVM, you can install Node 22 with
$ NVM_NODEJS_ORG_MIRROR=https://nodejs.org/download/nightly nvm install v22
# switch to use Node 22
$ nvm use 22
```
In `sample/src/main/scala/Sample.scala` you can find a sandbox to play around.

You can build and run it like any other Scala.js project from sbt:

- `sample/fastLinkJS` compiles and links the project with the WebAssembly backend.
- `sample/run` runs the sample linked to WebAssembly with `node`.

You may want to look at the output in `sample/target/scala-2.12/sample-fastopt/` to convince yourself that it was compiled to WebAssembly.

In that directory, you will also find a `main.wat` file, which is not used for execution.
It contains the WebAsembly Text Format representation of `main.wasm`, for debugging purposes.

:warning: If you modify the linker code, you need to `reload` and `sample/clean` for your changes to take effect on the sample.

You can also use the `run.mjs` script to play with `@JSExportTopLevel` exports.

- Run from the command line with `node run.mjs`.
- Run from the browser by starting an HTTP server (e.g., `python -m http.server`) and navigate to `testrun.html`.

### Test suite

Run the test suite with `tests/test`.

- `tests/test` will
- Run `testSuite/run` to compile the Scala code under `test-suite` to WebAssembly
- Run the WebAssembly binary using NodeJS
- Each Scala program in `test-suite` should have a function that has no arguments and return a Boolean value. The test passes if the function returns `true`.
- When you add a test,
- Each Scala program in `test-suite` should have a `def main(): Unit` function. The test passes if the function successfully executes without throwing.
- When you add a test,
- Add a file under `test-suite`
- Add a test case to `cli/src/main/scala/TestSuites.scala` (`methodName` should be a exported function name).

### Debugging tools

- The WasmGC reference interpreter can be used to validate and convert between the binary and text form:
- https://github.com/WebAssembly/gc/tree/main/interpreter
- Use docker image for it https://github.com/tanishiking/wasmgc-docker
33 changes: 10 additions & 23 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import org.scalajs.linker.interface.ESVersion
import org.scalajs.linker.interface.OutputPatterns

val scalaV = "2.13.12"
val scalaV = "2.12.19"

// Include wasm.jvm on the classpath used to dynamically load Scala.js linkers
Global / scalaJSLinkerImpl / fullClasspath :=
(wasm.jvm / Compile / fullClasspath).value

inThisBuild(Def.settings(
scalacOptions ++= Seq(
Expand Down Expand Up @@ -31,45 +35,28 @@ lazy val cli = project
),
)
.dependsOn(
wasm,
wasm.js,
// tests // for TestSuites constant
)

lazy val wasm = project
lazy val wasm = crossProject(JVMPlatform, JSPlatform)
.crossType(CrossType.Pure)
.in(file("wasm"))
.enablePlugins(ScalaJSPlugin)
.settings(
name := "wasm",
version := "0.1.0-SNAPSHOT",
scalaVersion := scalaV,
libraryDependencies ++= Seq(
"org.scala-js" %%% "scalajs-linker" % "1.16.0"
),
scalaJSUseMainModuleInitializer := true,
scalaJSLinkerConfig ~= {
_.withModuleKind(ModuleKind.CommonJSModule),
}
)
// .enablePlugins(ScalaJSPlugin)

lazy val sample = project
.in(file("sample"))
.enablePlugins(ScalaJSPlugin)
.enablePlugins(WasmLinkerPlugin)
.settings(
scalaVersion := scalaV,
scalaJSUseMainModuleInitializer := true,
Compile / jsEnv := {
import org.scalajs.jsenv.nodejs.NodeJSEnv
val cp = Attributed
.data((Compile / fullClasspath).value)
.mkString(";")
val env = Map(
"SCALAJS_CLASSPATH" -> cp,
"SCALAJS_MODE" -> "sample",
)
new NodeJSEnv(NodeJSEnv.Config().withEnv(env).withArgs(List("--enable-source-maps")))
},
Compile / jsEnvInput := (`cli` / Compile / jsEnvInput).value
)

lazy val testSuite = project
Expand Down Expand Up @@ -102,7 +89,7 @@ lazy val tests = project
"org.scala-js" %%% "scala-js-macrotask-executor" % "1.1.1" % Test
),
scalaJSLinkerConfig ~= {
// Generate CoreTests as an ES module so that it can import the loader.mjs
// Generate CoreTests as an ES module so that it can import the main.mjs files
// Give it an `.mjs` extension so that Node.js actually interprets it as an ES module
_.withModuleKind(ModuleKind.ESModule)
.withOutputPatterns(OutputPatterns.fromJSFile("%s.mjs")),
Expand Down
72 changes: 43 additions & 29 deletions cli/src/main/scala/Main.scala
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package cli

import scala.scalajs.js
import scala.scalajs.js.annotation._

import wasm.Compiler
import wasm.WebAssemblyLinkerImpl

import org.scalajs.linker.NodeOutputDirectory
import org.scalajs.linker.interface._

import org.scalajs.logging._

import org.scalajs.linker.interface.ModuleInitializer
import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits._

import scala.concurrent.Future
Expand All @@ -21,37 +26,39 @@ object Main {
throw new IllegalArgumentException("The classpath was not provided.")
}

val mode = (modeEnvVar: Any) match {
case modeEnvVar if modeEnvVar == "testsuite" => "testsuite"
case _ => "compile"
}
val linkerConfig = StandardConfig()
.withESFeatures(_.withESVersion(ESVersion.ES2016)) // to be able to link `**`
.withSemantics(_.optimized) // because that's the only thing we actually support at the moment
.withModuleKind(ModuleKind.ESModule)
.withOptimizer(false)
.withOutputPatterns(OutputPatterns.fromJSFile("%s.mjs"))

val logger = new ScalaConsoleLogger(Level.Info)

if ((modeEnvVar: Any) != "testsuite")
throw new IllegalArgumentException("The cli linker only supports the 'testsuite' mode")

val result =
if (mode == "testsuite") {
for {
irFiles <- new CliReader(classpath).irFiles
_ <- Future.sequence {
TestSuites.suites.map { case TestSuites.TestSuite(className, methodName) =>
val moduleInitializer = ModuleInitializer.mainMethod(className, methodName)
Compiler.compileIRFiles(
irFiles,
List(moduleInitializer),
s"$className"
)
}
for {
irFiles <- new CliReader(classpath).irFiles
_ <- Future.sequence {
TestSuites.suites.map { case TestSuites.TestSuite(className, methodName) =>
val linker = WebAssemblyLinkerImpl.linker(linkerConfig)
val moduleInitializer = ModuleInitializer.mainMethod(className, methodName)
val outputDir = s"./target/$className/"
createDir(outputDir)
val output = NodeOutputDirectory(outputDir)
linker.link(
irFiles,
List(moduleInitializer),
output,
logger
)
}
} yield {
println("Module successfully initialized")
()
}
} else {
for {
irFiles <- new CliReader(classpath).irFiles
_ <- Compiler.compileIRFiles(irFiles, Nil, s"output")
} yield {
println("Module successfully initialized")
()
}
} yield {
println("Module successfully initialized")
()
}

result.recover { case th: Throwable =>
Expand All @@ -60,4 +67,11 @@ object Main {
js.Dynamic.global.process.exit(1)
}
}

def createDir(dir: String): Unit =
mkdirSync(dir, js.Dynamic.literal(recursive = true))

@js.native
@JSImport("node:fs")
def mkdirSync(path: String, options: js.Object = js.native): Unit = js.native
}
51 changes: 51 additions & 0 deletions project/WasmLinkerPlugin.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package build

import sbt._
import sbt.Keys._

import org.scalajs.linker._
import org.scalajs.linker.interface.{ModuleKind, _}

import org.scalajs.sbtplugin._
import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport._

object WasmLinkerPlugin extends AutoPlugin {
override lazy val requires = ScalaJSPlugin

/** A `LinkerImpl` that reflectively loads our `WebAssemblyLinkerImpl`. */
private class WasmLinkerImpl(base: LinkerImpl.Reflect)
extends LinkerImpl.Forwarding(base) {

private val loader = base.loader

private val clearableLinkerMethod = {
Class.forName("wasm.WebAssemblyLinkerImpl", true, loader)
.getMethod("clearableLinker", classOf[StandardConfig])
}

override def clearableLinker(config: StandardConfig): ClearableLinker =
clearableLinkerMethod.invoke(null, config).asInstanceOf[ClearableLinker]
}

override def projectSettings: Seq[Setting[_]] = Def.settings(
// Use a separate cache box for the LinkerImpl in this project (don't use the Global one)
scalaJSLinkerImplBox := new CacheBox,

// Use our custom WasmLinkerImpl as the linker implementation used by fast/fullLinkJS
scalaJSLinkerImpl := {
val cp = (scalaJSLinkerImpl / fullClasspath).value
scalaJSLinkerImplBox.value.ensure {
new WasmLinkerImpl(LinkerImpl.reflect(Attributed.data(cp)))
}
},

// Automatically install all the configs required by the Wasm backend
scalaJSLinkerConfig ~= { prev =>
prev
.withModuleKind(ModuleKind.ESModule)
.withSemantics(_.optimized)
.withOutputPatterns(OutputPatterns.fromJSFile("%s.mjs"))
.withOptimizer(false)
},
)
}
1 change: 1 addition & 0 deletions project/plugins.sbt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0")
addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2")
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2")
9 changes: 4 additions & 5 deletions run.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { load } from "./loader.mjs";
import { test, field } from "./sample/target/scala-2.12/sample-fastopt/main.mjs";

const moduleExports = await load("./target/output.wasm");
console.log(moduleExports.field);
const o = moduleExports.test(7);
console.log(field);
const o = test(7);
console.log(o);
console.log(moduleExports.field);
console.log(field);
4 changes: 4 additions & 0 deletions sample/src/main/scala/Sample.scala
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ object Main {
true
}

def main(args: Array[String]): Unit = {
println("hello world")
}

private def println(x: Any): Unit =
js.Dynamic.global.console.log("" + x)
}
7 changes: 1 addition & 6 deletions tests/src/test/scala/tests/CoreTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,10 @@ import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits._
class CoreTests extends munit.FunSuite {
cli.TestSuites.suites.map { suite =>
test(suite.className) {
CoreTests.load(s"./target/${suite.className}.wasm").toFuture.map { _ =>
js.`import`[js.Any](s"../../../../target/${suite.className}/main.mjs").toFuture.map { _ =>
()
}
}
}

}

object CoreTests {
@js.native @JSImport("../../../../loader.mjs")
def load(wasmFile: String): js.Promise[js.Dynamic] = js.native
}
Loading