Skip to content

Commit cf1e65a

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 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 cf1e65a

18 files changed

Lines changed: 1086 additions & 225 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: 140 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,60 @@ 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+
// Node 24+ (V8 13+) has wasm-exnref enabled by default; older versions need --experimental-wasm-exnref.
211+
private def nodeNeedsWasmFlag: Boolean =
212+
nodeMajorVersion.forall(_ < 24) // true if unknown or < 24
213+
214+
// Detects the major version of Deno on PATH; cached for the JVM lifetime (lazy val).
215+
// Returns None if deno is not found or version cannot be parsed.
216+
private lazy val denoMajorVersion: Option[Int] =
217+
try {
218+
val process = new ProcessBuilder("deno", "--version")
219+
.redirectErrorStream(true)
220+
.start()
221+
val output = new String(process.getInputStream.readAllBytes()).trim
222+
process.waitFor()
223+
// Deno version format: "deno 2.1.0 (release, aarch64-apple-darwin)\nv8 13.x\ntypescript 5.x"
224+
// Extract major from first line
225+
val firstLine = output.linesIterator.nextOption().getOrElse("")
226+
val versionStr = firstLine.stripPrefix("deno ").takeWhile(c => c.isDigit || c == '.')
227+
versionStr.takeWhile(_.isDigit) match {
228+
case s if s.nonEmpty => Some(s.toInt)
229+
case _ => None
230+
}
231+
}
232+
catch {
233+
case _: Exception => None
234+
}
235+
236+
// Deno 2.x+ bundles V8 13+ which has wasm-exnref enabled by default; no flag needed.
237+
private def denoNeedsWasmFlag: Boolean =
238+
denoMajorVersion.flatMap { major =>
239+
if (major >= 2) Some(false) // Deno 2.x+ has V8 13+ with wasm-exnref by default
240+
else Some(true)
241+
}.getOrElse(true) // true if unknown
242+
189243
private def endsWithCaseInsensitive(s: String, suffix: String): Boolean =
190244
s.length >= suffix.length &&
191245
s.regionMatches(true, s.length - suffix.length, suffix, 0, suffix.length)
@@ -218,11 +272,13 @@ object Runner {
218272
def jsCommand(
219273
entrypoint: File,
220274
args: Seq[String],
221-
jsDom: Boolean = false
275+
jsDom: Boolean = false,
276+
emitWasm: Boolean = false
222277
): Seq[String] = {
223278

224-
val nodePath = findInPath("node").fold("node")(_.toString)
225-
val command = Seq(nodePath, entrypoint.getAbsolutePath) ++ args
279+
val nodePath = findInPath("node").fold("node")(_.toString)
280+
val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil
281+
val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
226282

227283
if (jsDom)
228284
// FIXME We'd need to replicate what JSDOMNodeJSEnv does under-the-hood to get the command in that case.
@@ -239,14 +295,16 @@ object Runner {
239295
allowExecve: Boolean = false,
240296
jsDom: Boolean = false,
241297
sourceMap: Boolean = false,
242-
esModule: Boolean = false
298+
esModule: Boolean = false,
299+
emitWasm: Boolean = false
243300
): Either[BuildException, Process] = either {
244301
val nodePath: String =
245302
value(findInPath("node")
246303
.map(_.toString)
247304
.toRight(NodeNotFoundError()))
305+
val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil
248306
if !jsDom && allowExecve && Execve.available() then {
249-
val command = Seq(nodePath, entrypoint.getAbsolutePath) ++ args
307+
val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
250308

251309
logger.log(
252310
s"Running ${command.mkString(" ")}",
@@ -262,12 +320,25 @@ object Runner {
262320
)
263321
sys.error("should not happen")
264322
}
323+
else if (emitWasm) {
324+
// For WASM mode with ES modules, run node directly instead of NodeJSEnv.
325+
// NodeJSEnv's stdin piping with "-" doesn't work with Input.ESModule.
326+
val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
327+
328+
logger.log(
329+
s"Running ${command.mkString(" ")}",
330+
" Running" + System.lineSeparator() +
331+
command.iterator.map(_ + System.lineSeparator()).mkString
332+
)
333+
334+
new ProcessBuilder(command: _*).inheritIO().start()
335+
}
265336
else {
266337
val nodeArgs =
267338
// Scala.js runs apps by piping JS to node.
268339
// If we need to pass arguments, we must first make the piped input explicit
269340
// with "-", and we pass the user's arguments after that.
270-
if args.isEmpty then Nil else "-" :: args.toList
341+
nodeFlags ++ (if args.isEmpty then Nil else "-" :: args.toList)
271342
val envJs =
272343
if jsDom then
273344
new JSDOMNodeJSEnv(
@@ -304,6 +375,69 @@ object Runner {
304375
}
305376
}
306377

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

0 commit comments

Comments
 (0)