diff --git a/.github/workflows/run-mill-action.yml b/.github/workflows/run-mill-action.yml index 84f4cc75b72..1629231c138 100644 --- a/.github/workflows/run-mill-action.yml +++ b/.github/workflows/run-mill-action.yml @@ -58,6 +58,11 @@ jobs: java-version: ${{ inputs.java-version }} distribution: temurin + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Prepare git config run: | git config --global user.name "Mill GithHub Actions" diff --git a/docs/modules/ROOT/pages/scalalib/web-examples.adoc b/docs/modules/ROOT/pages/scalalib/web-examples.adoc index 5ef5ece8807..f448f3b4bf8 100644 --- a/docs/modules/ROOT/pages/scalalib/web-examples.adoc +++ b/docs/modules/ROOT/pages/scalalib/web-examples.adoc @@ -35,4 +35,8 @@ include::partial$example/scalalib/web/6-cross-version-platform-publishing.adoc[] == Publishing Cross-Platform Scala Modules Alternative -include::partial$example/scalalib/web/7-cross-platform-version-publishing.adoc[] \ No newline at end of file +include::partial$example/scalalib/web/7-cross-platform-version-publishing.adoc[] + +== Scala.js WebAssembly Example + +include::partial$example/scalalib/web/8-wasm.adoc[] \ No newline at end of file diff --git a/example/scalalib/web/8-wasm/build.mill b/example/scalalib/web/8-wasm/build.mill new file mode 100644 index 00000000000..1eaa52edfa8 --- /dev/null +++ b/example/scalalib/web/8-wasm/build.mill @@ -0,0 +1,42 @@ +package build +import mill._, scalalib._, scalajslib._ +import mill.scalajslib.api._ + + +object wasm extends ScalaJSModule { + override def scalaVersion = "2.13.14" + + override def scalaJSVersion = "1.17.0" + + override def moduleKind = ModuleKind.ESModule + + override def moduleSplitStyle = ModuleSplitStyle.FewestModules + + override def scalaJSExperimentalUseWebAssembly = true +} + +// This build defines a single `ScalaJSModule` that uses the `WASM` backend of the scala JS linker. +// The release notes that introduced scalaJS wasm are here; +// https://www.scala-js.org/news/2024/09/28/announcing-scalajs-1.17.0/ +// and are worth reading. They include information such as the scala JS requirements to successfully emit wasm, +// the flags needed to run in browser and the minimum node version (22) required to actually run the wasm output. + +/** Usage + +> ./mill show wasm.fastLinkJS # mac/linux +{ +... +..."jsFileName": "main.js", +... + "dest": ".../out/wasm/fastLinkJS.dest" +} + +> node --experimental-wasm-exnref out/wasm/fastLinkJS.dest/main.js # mac/linux +hello wasm! + +*/ + +// Here we see that scala JS emits a single WASM module, as well as a loader and main.js file. +// `main.js` is the entry point of the program, and calls into the wasm module. + + diff --git a/example/scalalib/web/8-wasm/wasm/src/hello.scala b/example/scalalib/web/8-wasm/wasm/src/hello.scala new file mode 100644 index 00000000000..18b869667f7 --- /dev/null +++ b/example/scalalib/web/8-wasm/wasm/src/hello.scala @@ -0,0 +1,7 @@ +package wasm + +object wasm { + def main(args: Array[String]): Unit = { + println("hello wasm!") + } +} diff --git a/scalajslib/src/mill/scalajslib/ScalaJSModule.scala b/scalajslib/src/mill/scalajslib/ScalaJSModule.scala index 10b412d1741..db3248000a9 100644 --- a/scalajslib/src/mill/scalajslib/ScalaJSModule.scala +++ b/scalajslib/src/mill/scalajslib/ScalaJSModule.scala @@ -140,7 +140,8 @@ trait ScalaJSModule extends scalalib.ScalaModule { outer => moduleSplitStyle = moduleSplitStyle(), outputPatterns = scalaJSOutputPatterns(), minify = scalaJSMinify(), - importMap = scalaJSImportMap() + importMap = scalaJSImportMap(), + experimentalUseWebAssembly = scalaJSExperimentalUseWebAssembly() ) } @@ -191,7 +192,8 @@ trait ScalaJSModule extends scalalib.ScalaModule { outer => moduleSplitStyle: ModuleSplitStyle, outputPatterns: OutputPatterns, minify: Boolean, - importMap: Seq[ESModuleImportMapping] + importMap: Seq[ESModuleImportMapping], + experimentalUseWebAssembly: Boolean )(implicit ctx: mill.api.Ctx): Result[Report] = { val outputPath = ctx.dest @@ -212,7 +214,8 @@ trait ScalaJSModule extends scalalib.ScalaModule { outer => moduleSplitStyle = moduleSplitStyle, outputPatterns = outputPatterns, minify = minify, - importMap = importMap + importMap = importMap, + experimentalUseWebAssembly = experimentalUseWebAssembly ) } @@ -293,6 +296,21 @@ trait ScalaJSModule extends scalalib.ScalaModule { outer => /** Whether to emit a source map. */ def scalaJSSourceMap: T[Boolean] = Task { true } + /** + * Specifies whether to use the experimental WebAssembly backend.. Requires scalaJS > 1.17.0 + * When using this setting, the following properties must also hold: + * + * - `moduleKind = ModuleKind.ESModule` + * - `moduleSplitStyle = ModuleSplitStyle.FewestModules` + * + * @note + * Currently, the WebAssembly backend silently ignores `@JSExport` and + * `@JSExportAll` annotations. This behavior may change in the future, + * either by making them warnings or errors, or by adding support for them. + * All other language features are supported. + */ + def scalaJSExperimentalUseWebAssembly: T[Boolean] = Task { false } + /** Name patterns for output. */ def scalaJSOutputPatterns: T[OutputPatterns] = Task { OutputPatterns.Defaults } @@ -370,7 +388,8 @@ trait TestScalaJSModule extends ScalaJSModule with TestModule { moduleSplitStyle = moduleSplitStyle(), outputPatterns = scalaJSOutputPatterns(), minify = scalaJSMinify(), - importMap = scalaJSImportMap() + importMap = scalaJSImportMap(), + experimentalUseWebAssembly = scalaJSExperimentalUseWebAssembly() ) } diff --git a/scalajslib/src/mill/scalajslib/worker/ScalaJSWorker.scala b/scalajslib/src/mill/scalajslib/worker/ScalaJSWorker.scala index 321e0b55cf8..3ce55097cc0 100644 --- a/scalajslib/src/mill/scalajslib/worker/ScalaJSWorker.scala +++ b/scalajslib/src/mill/scalajslib/worker/ScalaJSWorker.scala @@ -169,7 +169,8 @@ private[scalajslib] class ScalaJSWorker extends AutoCloseable { moduleSplitStyle: api.ModuleSplitStyle, outputPatterns: api.OutputPatterns, minify: Boolean, - importMap: Seq[api.ESModuleImportMapping] + importMap: Seq[api.ESModuleImportMapping], + experimentalUseWebAssembly: Boolean )(implicit ctx: Ctx.Home): Result[api.Report] = { bridge(toolsClasspath).link( runClasspath = runClasspath.iterator.map(_.path.toNIO).toSeq, @@ -185,7 +186,8 @@ private[scalajslib] class ScalaJSWorker extends AutoCloseable { moduleSplitStyle = toWorkerApi(moduleSplitStyle), outputPatterns = toWorkerApi(outputPatterns), minify = minify, - importMap = importMap.map(toWorkerApi) + importMap = importMap.map(toWorkerApi), + experimentalUseWebAssembly = experimentalUseWebAssembly ) match { case Right(report) => Result.Success(fromWorkerApi(report)) case Left(message) => Result.Failure(message) diff --git a/scalajslib/test/resources/wasm/src/app/App.scala b/scalajslib/test/resources/wasm/src/app/App.scala new file mode 100644 index 00000000000..5bd4668058b --- /dev/null +++ b/scalajslib/test/resources/wasm/src/app/App.scala @@ -0,0 +1,10 @@ +package app + +import scala.scalajs.js +import scala.scalajs.js.annotation._ + +object App { + def main(args: Array[String]): Unit = { + println("hello, wasm!") + } +} diff --git a/scalajslib/test/src/mill/scalajslib/WasmTests.scala b/scalajslib/test/src/mill/scalajslib/WasmTests.scala new file mode 100644 index 00000000000..a43f372be9b --- /dev/null +++ b/scalajslib/test/src/mill/scalajslib/WasmTests.scala @@ -0,0 +1,87 @@ +package mill.scalajslib + +import mill.api.Result +import mill.define.Discover +import mill.testkit.UnitTester +import mill.testkit.TestBaseModule +import utest._ +import mill.scalajslib.api._ +import mill.T + +object WasmTests extends TestSuite { + val remapTo = "https://cdn.jsdelivr.net/gh/stdlib-js/array-base-linspace@esm/index.mjs" + + object Wasm extends TestBaseModule with ScalaJSModule { + override def scalaVersion = sys.props.getOrElse("TEST_SCALA_2_13_VERSION", ???) + + override def scalaJSVersion = "1.17.0" + + override def moduleKind = ModuleKind.ESModule + + override def moduleSplitStyle = ModuleSplitStyle.FewestModules + + override def scalaJSExperimentalUseWebAssembly: T[Boolean] = true + + override lazy val millDiscover = { + import mill.main.TokenReaders.given + Discover[this.type] + } + } + + object OldWasmModule extends TestBaseModule with ScalaJSModule { + override def scalaVersion = sys.props.getOrElse("TEST_SCALA_2_13_VERSION", ???) + override def scalaJSVersion = "1.16.0" + + override def moduleKind = ModuleKind.ESModule + override def moduleSplitStyle = ModuleSplitStyle.FewestModules + + override def scalaJSExperimentalUseWebAssembly: T[Boolean] = true + + override lazy val millDiscover = { + import mill.main.TokenReaders.given + Discover[this.type] + } + } + + val millSourcePath = os.Path(sys.env("MILL_TEST_RESOURCE_DIR")) / "wasm" + + val tests: Tests = Tests { + test("should emit wasm") { + val evaluator = UnitTester(Wasm, millSourcePath) + val Right(result) = + evaluator(Wasm.fastLinkJS) + val publicModules = result.value.publicModules.toSeq + val path = result.value.dest.path + val main = publicModules.head + assert(main.jsFileName == "main.js") + val mainPath = path / "main.js" + assert(os.exists(mainPath)) + val wasmPath = path / "main.wasm" + assert(os.exists(wasmPath)) + val wasmMapPath = path / "main.wasm.map" + assert(os.exists(wasmMapPath)) + } + + test("wasm is runnable") { + val evaluator = UnitTester(Wasm, millSourcePath) + val Right(result) = evaluator(Wasm.fastLinkJS) + val path = result.value.dest.path + os.proc("node", "--experimental-wasm-exnref", "main.js").call( + cwd = path, + check = true, + stdin = os.Inherit, + stdout = os.Inherit, + stderr = os.Inherit + ) + + } + + test("should throw for older scalaJS versions") { + val evaluator = UnitTester(OldWasmModule, millSourcePath) + val Left(Result.Exception(ex, _)) = evaluator(OldWasmModule.fastLinkJS) + val error = ex.getMessage + assert(error == "Emitting wasm is not supported with Scala.js < 1.17") + } + + } +} diff --git a/scalajslib/worker-api/src/mill/scalajslib/worker/api/ScalaJSWorkerApi.scala b/scalajslib/worker-api/src/mill/scalajslib/worker/api/ScalaJSWorkerApi.scala index b28a9358d8a..d9a7601c035 100644 --- a/scalajslib/worker-api/src/mill/scalajslib/worker/api/ScalaJSWorkerApi.scala +++ b/scalajslib/worker-api/src/mill/scalajslib/worker/api/ScalaJSWorkerApi.scala @@ -18,7 +18,8 @@ private[scalajslib] trait ScalaJSWorkerApi { moduleSplitStyle: ModuleSplitStyle, outputPatterns: OutputPatterns, minify: Boolean, - importMap: Seq[ESModuleImportMapping] + importMap: Seq[ESModuleImportMapping], + experimentalUseWebAssembly: Boolean ): Either[String, Report] def run(config: JsEnvConfig, report: Report): Unit diff --git a/scalajslib/worker/1/src/mill/scalajslib/worker/ScalaJSWorkerImpl.scala b/scalajslib/worker/1/src/mill/scalajslib/worker/ScalaJSWorkerImpl.scala index d6f2164ff72..f1022fae005 100644 --- a/scalajslib/worker/1/src/mill/scalajslib/worker/ScalaJSWorkerImpl.scala +++ b/scalajslib/worker/1/src/mill/scalajslib/worker/ScalaJSWorkerImpl.scala @@ -37,7 +37,8 @@ class ScalaJSWorkerImpl extends ScalaJSWorkerApi { moduleSplitStyle: ModuleSplitStyle, outputPatterns: OutputPatterns, minify: Boolean, - dest: File + dest: File, + experimentalUseWebAssembly: Boolean ) private def minorIsGreaterThanOrEqual(number: Int) = ScalaJSVersions.current match { case s"1.$n.$_" if n.toIntOption.exists(_ < number) => false @@ -153,7 +154,15 @@ class ScalaJSWorkerImpl extends ScalaJSWorkerApi { if (minorIsGreaterThanOrEqual(16)) withOutputPatterns.withMinify(input.minify) else withOutputPatterns - val linker = StandardImpl.clearableLinker(withMinify) + val withWasm = + (minorIsGreaterThanOrEqual(17), input.experimentalUseWebAssembly) match { + case (_, false) => withMinify + case (true, true) => withMinify.withExperimentalUseWebAssembly(true) + case (false, true) => + throw new Exception("Emitting wasm is not supported with Scala.js < 1.17") + } + + val linker = StandardImpl.clearableLinker(withWasm) val irFileCacheCache = irFileCache.newCache (linker, irFileCacheCache) } @@ -180,7 +189,8 @@ class ScalaJSWorkerImpl extends ScalaJSWorkerApi { moduleSplitStyle: ModuleSplitStyle, outputPatterns: OutputPatterns, minify: Boolean, - importMap: Seq[ESModuleImportMapping] + importMap: Seq[ESModuleImportMapping], + experimentalUseWebAssembly: Boolean ): Either[String, Report] = { // On Scala.js 1.2- we want to use the legacy mode either way since // the new mode is not supported and in tests we always use legacy = false @@ -195,7 +205,8 @@ class ScalaJSWorkerImpl extends ScalaJSWorkerApi { moduleSplitStyle = moduleSplitStyle, outputPatterns = outputPatterns, minify = minify, - dest = dest + dest = dest, + experimentalUseWebAssembly = experimentalUseWebAssembly )) val irContainersAndPathsFuture = PathIRContainer.fromClasspath(runClasspath) val testInitializer =