Skip to content

Commit c6453eb

Browse files
author
v.karamyshev
committed
Add WASM support: --wasm flag with Node.js and Deno runtimes
Implements scala-cli issue VirtusLab#3316: integrate WebAssembly with Scala CLI. - `--wasm` CLI flag and `//> using wasm` directive to enable WASM output - `--wasm-runtime <runtime>` option and `//> using wasmRuntime` directive Supported values: node (default), deno, wasmtime, wasmedge, wasmer - `--deno-version`, `--wasmtime-version`, `--wasmer-version` options and corresponding directives for pinning runtime versions - **Node.js** (default): runs Scala.js WASM output with `--experimental-wasm-exnref` flag, requires Node.js >= 22 - **Deno**: runs Scala.js WASM output; if not found on PATH, downloads automatically from GitHub releases via Coursier cache - **Wasmtime / WasmEdge / Wasmer**: return UnsupportedWasmRuntimeError pending upstream Scala.js standalone WASM support (scala-js/scala-js#4991)
1 parent b153014 commit c6453eb

18 files changed

Lines changed: 860 additions & 23 deletions

File tree

build.mill

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,8 @@ trait Core extends ScalaCliCrossSbtModule
520520
| def toolkitVersionForNative04 = "${Deps.toolkitVersionForNative04}"
521521
| def toolkitVersionForNative05 = "${Deps.toolkitVersionForNative05}"
522522
|
523+
| def defaultDenoVersion = "2.1.4"
524+
|
523525
| def typelevelOrganization = "${Deps.typelevelToolkit.dep.module.organization.value}"
524526
| def typelevelToolkitDefaultVersion = "${Deps.typelevelToolkitVersion}"
525527
| def typelevelToolkitMaxScalaNative = "${Deps.Versions.maxScalaNativeForTypelevelToolkit}"

modules/build/src/main/scala/scala/build/internal/Runner.scala

Lines changed: 128 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,62 @@ object Runner {
186186
run(command, logger, cwd = cwd, extraEnv = extraEnv)
187187
}
188188

189+
// Detects the major version of Node.js on PATH; cached for the JVM lifetime (lazy val).
190+
// Returns None if node is not found or version cannot be parsed.
191+
private lazy val nodeMajorVersion: Option[Int] = {
192+
try {
193+
val process = new ProcessBuilder("node", "--version")
194+
.redirectErrorStream(true)
195+
.start()
196+
val output = new String(process.getInputStream.readAllBytes()).trim
197+
process.waitFor()
198+
// Node version format: "v22.5.0" -> extract 22
199+
if (output.startsWith("v"))
200+
output.drop(1).takeWhile(_.isDigit) match {
201+
case s if s.nonEmpty => Some(s.toInt)
202+
case _ => None
203+
}
204+
else None
205+
}
206+
catch {
207+
case _: Exception => None
208+
}
209+
}
210+
211+
// Node 24+ (V8 13+) has wasm-exnref enabled by default; older versions need --experimental-wasm-exnref.
212+
private def nodeNeedsWasmFlag: Boolean =
213+
nodeMajorVersion.forall(_ < 24) // true if unknown or < 24
214+
215+
// Detects the major version of Deno on PATH; cached for the JVM lifetime (lazy val).
216+
// Returns None if deno is not found or version cannot be parsed.
217+
private lazy val denoMajorVersion: Option[Int] = {
218+
try {
219+
val process = new ProcessBuilder("deno", "--version")
220+
.redirectErrorStream(true)
221+
.start()
222+
val output = new String(process.getInputStream.readAllBytes()).trim
223+
process.waitFor()
224+
// Deno version format: "deno 2.1.0 (release, aarch64-apple-darwin)\nv8 13.x\ntypescript 5.x"
225+
// Extract major from first line
226+
val firstLine = output.linesIterator.nextOption().getOrElse("")
227+
val versionStr = firstLine.stripPrefix("deno ").takeWhile(c => c.isDigit || c == '.')
228+
versionStr.takeWhile(_.isDigit) match {
229+
case s if s.nonEmpty => Some(s.toInt)
230+
case _ => None
231+
}
232+
}
233+
catch {
234+
case _: Exception => None
235+
}
236+
}
237+
238+
// Deno 2.x+ bundles V8 13+ which has wasm-exnref enabled by default; no flag needed.
239+
private def denoNeedsWasmFlag: Boolean =
240+
denoMajorVersion.flatMap { major =>
241+
if (major >= 2) Some(false) // Deno 2.x+ has V8 13+ with wasm-exnref by default
242+
else Some(true)
243+
}.getOrElse(true) // true if unknown
244+
189245
private def endsWithCaseInsensitive(s: String, suffix: String): Boolean =
190246
s.length >= suffix.length &&
191247
s.regionMatches(true, s.length - suffix.length, suffix, 0, suffix.length)
@@ -218,11 +274,13 @@ object Runner {
218274
def jsCommand(
219275
entrypoint: File,
220276
args: Seq[String],
221-
jsDom: Boolean = false
277+
jsDom: Boolean = false,
278+
emitWasm: Boolean = false
222279
): Seq[String] = {
223280

224281
val nodePath = findInPath("node").fold("node")(_.toString)
225-
val command = Seq(nodePath, entrypoint.getAbsolutePath) ++ args
282+
val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil
283+
val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
226284

227285
if (jsDom)
228286
// FIXME We'd need to replicate what JSDOMNodeJSEnv does under-the-hood to get the command in that case.
@@ -239,14 +297,16 @@ object Runner {
239297
allowExecve: Boolean = false,
240298
jsDom: Boolean = false,
241299
sourceMap: Boolean = false,
242-
esModule: Boolean = false
300+
esModule: Boolean = false,
301+
emitWasm: Boolean = false
243302
): Either[BuildException, Process] = either {
244303
val nodePath: String =
245304
value(findInPath("node")
246305
.map(_.toString)
247306
.toRight(NodeNotFoundError()))
307+
val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil
248308
if !jsDom && allowExecve && Execve.available() then {
249-
val command = Seq(nodePath, entrypoint.getAbsolutePath) ++ args
309+
val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
250310

251311
logger.log(
252312
s"Running ${command.mkString(" ")}",
@@ -267,7 +327,7 @@ object Runner {
267327
// Scala.js runs apps by piping JS to node.
268328
// If we need to pass arguments, we must first make the piped input explicit
269329
// with "-", and we pass the user's arguments after that.
270-
if args.isEmpty then Nil else "-" :: args.toList
330+
nodeFlags ++ (if args.isEmpty then Nil else "-" :: args.toList)
271331
val envJs =
272332
if jsDom then
273333
new JSDOMNodeJSEnv(
@@ -304,6 +364,69 @@ object Runner {
304364
}
305365
}
306366

367+
def denoCommand(
368+
entrypoint: File,
369+
args: Seq[String],
370+
denoPathOpt: Option[String] = None
371+
): Seq[String] = {
372+
val denoPath = denoPathOpt.getOrElse(findInPath("deno").fold("deno")(_.toString))
373+
val denoFlags = Seq("run", "--allow-read")
374+
Seq(denoPath) ++ denoFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
375+
}
376+
377+
def runDeno(
378+
entrypoint: File,
379+
args: Seq[String],
380+
logger: Logger,
381+
allowExecve: Boolean = false,
382+
emitWasm: Boolean = false,
383+
denoPathOpt: Option[String] = None
384+
): Either[BuildException, Process] = either {
385+
val denoPath: String = denoPathOpt.getOrElse {
386+
value(findInPath("deno")
387+
.map(_.toString)
388+
.toRight(DenoNotFoundError()))
389+
}
390+
val denoFlags = Seq("run", "--allow-read")
391+
val extraEnv =
392+
if (emitWasm && denoNeedsWasmFlag) Map("DENO_V8_FLAGS" -> "--experimental-wasm-exnref")
393+
else Map.empty
394+
395+
if (allowExecve && Execve.available()) {
396+
val command = Seq(denoPath) ++ denoFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
397+
398+
logger.log(
399+
s"Running ${command.mkString(" ")}",
400+
" Running" + System.lineSeparator() +
401+
command.iterator.map(_ + System.lineSeparator()).mkString
402+
)
403+
404+
logger.debug("execve available")
405+
Execve.execve(
406+
command.head,
407+
"deno" +: command.tail.toArray,
408+
(sys.env ++ extraEnv).toArray.sorted.map { case (k, v) => s"$k=$v" }
409+
)
410+
sys.error("should not happen")
411+
}
412+
else {
413+
val command = Seq(denoPath) ++ denoFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
414+
415+
logger.log(
416+
s"Running ${command.mkString(" ")}",
417+
" Running" + System.lineSeparator() +
418+
command.iterator.map(_ + System.lineSeparator()).mkString
419+
)
420+
421+
val builder = new ProcessBuilder(command*)
422+
.inheritIO()
423+
val env = builder.environment()
424+
for ((k, v) <- extraEnv)
425+
env.put(k, v)
426+
builder.start()
427+
}
428+
}
429+
307430
def runNative(
308431
launcher: File,
309432
args: Seq[String],

modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ object DirectivesPreprocessingUtils {
3131
directives.ScalaNative.handler,
3232
directives.ScalaVersion.handler,
3333
directives.Sources.handler,
34-
directives.Tests.handler
34+
directives.Tests.handler,
35+
directives.Wasm.handler
3536
).map(_.mapE(_.buildOptions))
3637

3738
val usingDirectiveWithReqsHandlers

modules/cli/src/main/scala/scala/cli/commands/fix/BuiltInRules.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ object BuiltInRules extends CommandHelpers {
387387
JavaHome.handler.keys,
388388
ScalaNative.handler.keys,
389389
ScalaJs.handler.keys,
390+
Wasm.handler.keys,
390391
ScalacOptions.handler.keys,
391392
JavaOptions.handler.keys,
392393
JavacOptions.handler.keys,

modules/cli/src/main/scala/scala/cli/commands/run/Run.scala

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ import java.util.concurrent.atomic.AtomicReference
1212
import scala.build.*
1313
import scala.build.EitherCps.{either, value}
1414
import scala.build.Ops.*
15-
import scala.build.errors.{BuildException, CompositeBuildException}
15+
import scala.build.errors.{BuildException, CompositeBuildException, UnsupportedWasmRuntimeError}
1616
import scala.build.input.*
1717
import scala.build.internal.{Constants, Runner, ScalaJsLinkerConfig}
1818
import scala.build.internals.ConsoleUtils.ScalaCliConsole
1919
import scala.build.internals.ConsoleUtils.ScalaCliConsole.warnPrefix
2020
import scala.build.internals.EnvVar
21-
import scala.build.options.{BuildOptions, JavaOpt, PackageType, Platform, Scope}
21+
import scala.build.options.{BuildOptions, JavaOpt, PackageType, Platform, Scope, WasmRuntime}
2222
import scala.cli.CurrentParams
2323
import scala.cli.commands.package0.Package
2424
import scala.cli.commands.setupide.SetupIde
@@ -28,7 +28,7 @@ import scala.cli.commands.util.BuildCommandHelpers.*
2828
import scala.cli.commands.util.{BuildCommandHelpers, RunHadoop, RunSpark}
2929
import scala.cli.commands.{CommandUtils, ScalaCommand, SpecificationLevel, WatchUtil}
3030
import scala.cli.config.Keys
31-
import scala.cli.internal.ProcUtil
31+
import scala.cli.internal.{ProcUtil, WasmRuntimeDownloader}
3232
import scala.cli.packaging.Library.fullClassPathMaybeAsJar
3333
import scala.cli.util.ArgHelpers.*
3434
import scala.cli.util.ConfigDbUtils
@@ -474,12 +474,112 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
474474
if shouldLogCrossInfo then logger.debug(s"Running build for ${crossBuildParams.asString}")
475475
val build = builds.head
476476
either {
477-
build.options.platform.value match {
477+
val wasmOpts = build.options.wasmOptions
478+
479+
// Check if WASM mode is requested
480+
if wasmOpts.enabled then {
481+
val runtime = wasmOpts.runtime
482+
483+
if runtime.isJsBased then {
484+
// JS-based WASM path - uses Scala.js WASM with JavaScript helpers (Node.js or Deno)
485+
val esModule = true // WASM backend uses ES modules
486+
scratchDirOpt.foreach(os.makeDir.all(_))
487+
val jsDest = os.temp(
488+
dir = scratchDirOpt.orNull,
489+
prefix = "main",
490+
suffix = ".mjs",
491+
deleteOnExit = scratchDirOpt.isEmpty
492+
)
493+
494+
// Resolve Deno binary: check PATH first, download if needed
495+
val denoPathOpt: Option[String] = runtime match {
496+
case WasmRuntime.Deno =>
497+
val denoCmd = value(WasmRuntimeDownloader.denoCommand(
498+
wasmOpts.finalDenoVersion,
499+
build.options.archiveCache,
500+
logger
501+
))
502+
Some(denoCmd.head)
503+
case _ => None
504+
}
505+
506+
val linkerConfig = build.options.scalaJsOptions.linkerConfig(logger)
507+
.copy(emitWasm = true, moduleKind = ScalaJsLinkerConfig.ModuleKind.ESModule)
508+
509+
val res = Package.linkJs(
510+
builds = builds,
511+
dest = jsDest,
512+
mainClassOpt = Some(mainClass),
513+
addTestInitializer = false,
514+
config = linkerConfig,
515+
fullOpt = value(build.options.scalaJsOptions.fullOpt),
516+
noOpt = build.options.scalaJsOptions.noOpt.getOrElse(false),
517+
logger = logger,
518+
scratchDirOpt = scratchDirOpt
519+
).map { outputPath =>
520+
if showCommand then
521+
runtime match {
522+
case WasmRuntime.Deno =>
523+
Left(Runner.denoCommand(outputPath.toIO, args, denoPathOpt = denoPathOpt))
524+
case _ =>
525+
Left(Runner.jsCommand(outputPath.toIO, args, jsDom = false, emitWasm = true))
526+
}
527+
else {
528+
val process = value {
529+
runtime match {
530+
case WasmRuntime.Deno =>
531+
Runner.runDeno(
532+
outputPath.toIO,
533+
args,
534+
logger,
535+
allowExecve = effectiveAllowExecve,
536+
emitWasm = true,
537+
denoPathOpt = denoPathOpt
538+
)
539+
case _ =>
540+
Runner.runJs(
541+
outputPath.toIO,
542+
args,
543+
logger,
544+
allowExecve = effectiveAllowExecve,
545+
jsDom = false,
546+
sourceMap = build.options.scalaJsOptions.emitSourceMaps,
547+
esModule = esModule,
548+
emitWasm = true
549+
)
550+
}
551+
}
552+
process.onExit().thenApply(_ => if os.exists(jsDest) then os.remove(jsDest))
553+
Right((process, None))
554+
}
555+
}
556+
value(res)
557+
}
558+
else {
559+
// Standalone WASM runtimes - not yet supported.
560+
// Scala.js currently produces JS-dependent WASM output.
561+
// Standalone support requires upstream Scala.js changes (scala-js/scala-js#4991).
562+
val runtimeName = runtime.name
563+
val extraNote = runtime match {
564+
case WasmRuntime.Wasmer =>
565+
" Note: Wasmer does not yet support WasmGC, which is required for Scala WASM output."
566+
case _ => ""
567+
}
568+
value(Left(new UnsupportedWasmRuntimeError(
569+
s"Standalone WASM runtime '$runtimeName' is not yet supported." +
570+
s"$extraNote" +
571+
" Scala.js currently produces JavaScript-dependent WASM output." +
572+
" Standalone WASM support is tracked at: https://github.com/scala-js/scala-js/issues/4991" +
573+
" Use --wasm-runtime node (default) or --wasm-runtime deno for JS-based WASM execution."
574+
)))
575+
}
576+
}
577+
else build.options.platform.value match {
478578
case Platform.JS =>
479579
val esModule =
480580
build.options.scalaJsOptions.moduleKindStr.exists(m => m == "es" || m == "esmodule")
481581

482-
val linkerConfig = builds.head.options.scalaJsOptions.linkerConfig(logger)
582+
val linkerConfig = build.options.scalaJsOptions.linkerConfig(logger)
483583
val jsDest = {
484584
val delete = scratchDirOpt.isEmpty
485585
scratchDirOpt.foreach(os.makeDir.all(_))

modules/cli/src/main/scala/scala/cli/commands/shared/HelpGroups.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ enum HelpGroup:
1717
Scala, ScalaJs, ScalaNative, Secret, Signing, SuppressWarnings, SourceGenerator,
1818
Test,
1919
Uninstall, Update,
20-
Watch, Windows,
20+
Wasm, Watch, Windows,
2121
Version
2222

2323
override def toString: String = this match
@@ -30,6 +30,7 @@ enum HelpGroup:
3030
case SuppressWarnings => "Suppress warnings"
3131
case SourceGenerator => "Source generator"
3232
case ProjectVersion => "Project version"
33+
case Wasm => "WebAssembly"
3334
case e => e.productPrefix
3435

3536
enum HelpCommandGroup:

0 commit comments

Comments
 (0)