Skip to content

Commit 2e97668

Browse files
committed
Add WASM support: --wasm flag with Node.js and Deno runtimes
Implements scala-cli issue #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 - `--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
1 parent 738d820 commit 2e97668

18 files changed

Lines changed: 1086 additions & 226 deletions

File tree

build.mill

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,8 @@ trait Core extends ScalaCliCrossSbtModule
533533
| def toolkitVersionForNative04 = "${Deps.toolkitVersionForNative04}"
534534
| def toolkitVersionForNative05 = "${Deps.toolkitVersionForNative05}"
535535
|
536+
| def defaultDenoVersion = "2.1.4"
537+
|
536538
| def typelevelOrganization = "${Deps.typelevelToolkit.dep.module.organization.value}"
537539
| def typelevelToolkitDefaultVersion = "${Deps.typelevelToolkitVersion}"
538540
| 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
@@ -189,6 +189,60 @@ object Runner {
189189
run(command, logger, cwd = cwd, extraEnv = extraEnv)
190190
}
191191

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

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

230286
if (jsDom)
231287
// FIXME We'd need to replicate what JSDOMNodeJSEnv does under-the-hood to get the command in that case.
@@ -242,14 +298,16 @@ object Runner {
242298
allowExecve: Boolean = false,
243299
jsDom: Boolean = false,
244300
sourceMap: Boolean = false,
245-
esModule: Boolean = false
301+
esModule: Boolean = false,
302+
emitWasm: Boolean = false
246303
): Either[BuildException, Process] = either {
247304
val nodePath: String =
248305
value(findInPath("node")
249306
.map(_.toString)
250307
.toRight(NodeNotFoundError()))
308+
val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil
251309
if !jsDom && allowExecve && Execve.available() then {
252-
val command = Seq(nodePath, entrypoint.getAbsolutePath) ++ args
310+
val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
253311

254312
logger.log(
255313
s"Running ${command.mkString(" ")}",
@@ -265,12 +323,25 @@ object Runner {
265323
)
266324
sys.error("should not happen")
267325
}
326+
else if (emitWasm) {
327+
// For WASM mode with ES modules, run node directly instead of NodeJSEnv.
328+
// NodeJSEnv's stdin piping with "-" doesn't work with Input.ESModule.
329+
val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
330+
331+
logger.log(
332+
s"Running ${command.mkString(" ")}",
333+
" Running" + System.lineSeparator() +
334+
command.iterator.map(_ + System.lineSeparator()).mkString
335+
)
336+
337+
new ProcessBuilder(command: _*).inheritIO().start()
338+
}
268339
else {
269340
val nodeArgs =
270341
// Scala.js runs apps by piping JS to node.
271342
// If we need to pass arguments, we must first make the piped input explicit
272343
// with "-", and we pass the user's arguments after that.
273-
if args.isEmpty then Nil else "-" :: args.toList
344+
nodeFlags ++ (if args.isEmpty then Nil else "-" :: args.toList)
274345
val envJs =
275346
if jsDom then
276347
new JSDOMNodeJSEnv(
@@ -307,6 +378,69 @@ object Runner {
307378
}
308379
}
309380

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

3839
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)