Skip to content

Commit d494c98

Browse files
committed
Wasm: probe runtime for exnref, rename to JSRuntime, test Deno/Bun on CI, add guide
- Rename WasmRuntime -> JSRuntime and move --js-wasm-runtime into the JS option group - Detect whether each runtime accepts --experimental-wasm-exnref instead of hardcoding or version-gating it. The Scala.js Wasm backend compiles exceptions with the exnref proposal, but whether a runtime needs the flag is non-monotonic across versions: a throwing program runs unflagged on Node 22.22 and 24 yet crashes on Node 22.0 and 23.11, while Deno 2.8 (V8 14.9) removed the flag entirely (passing it aborts). So Scala CLI probes the binary once and passes the flag only when accepted - injected where exnref is still gated, skipped where it was removed. Node takes it as a CLI arg, Deno via DENO_V8_FLAGS; both log when applied. Bun runs on JavaScriptCore and needs no flag. - Add integration tests exercising real exception handling under Wasm on deno and bun (a throwing program, unlike println, actually needs exnref) - Log implicit ES-module enabling only when --js-module-kind is unset; only warn "module-kind ignored" when the chosen kind isn't ES - Install Deno and Bun in all five jvm-tests CI jobs so the guarded Deno/Bun Wasm tests actually run - Add guides/advanced/scala-wasm.md and regenerate the reference docs
1 parent eabfdbe commit d494c98

13 files changed

Lines changed: 260 additions & 102 deletions

File tree

.github/workflows/ci.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,10 @@ jobs:
202202
- uses: actions/setup-node@v6
203203
with:
204204
node-version: 24
205+
- uses: denoland/setup-deno@v2
206+
with:
207+
deno-version: v2.x
208+
- uses: oven-sh/setup-bun@v2
205209
- name: JVM integration tests
206210
if: env.SHOULD_RUN == 'true'
207211
run: ./mill -i integration.test.jvm
@@ -243,6 +247,10 @@ jobs:
243247
- uses: actions/setup-node@v6
244248
with:
245249
node-version: 24
250+
- uses: denoland/setup-deno@v2
251+
with:
252+
deno-version: v2.x
253+
- uses: oven-sh/setup-bun@v2
246254
- name: JVM integration tests
247255
if: env.SHOULD_RUN == 'true'
248256
run: ./mill -i integration.test.jvm
@@ -284,6 +292,10 @@ jobs:
284292
- uses: actions/setup-node@v6
285293
with:
286294
node-version: 24
295+
- uses: denoland/setup-deno@v2
296+
with:
297+
deno-version: v2.x
298+
- uses: oven-sh/setup-bun@v2
287299
- name: JVM integration tests
288300
if: env.SHOULD_RUN == 'true'
289301
run: ./mill -i integration.test.jvm
@@ -325,6 +337,10 @@ jobs:
325337
- uses: actions/setup-node@v6
326338
with:
327339
node-version: 24
340+
- uses: denoland/setup-deno@v2
341+
with:
342+
deno-version: v2.x
343+
- uses: oven-sh/setup-bun@v2
328344
- name: JVM integration tests
329345
if: env.SHOULD_RUN == 'true'
330346
run: ./mill -i integration.test.jvm
@@ -366,6 +382,10 @@ jobs:
366382
- uses: actions/setup-node@v6
367383
with:
368384
node-version: 24
385+
- uses: denoland/setup-deno@v2
386+
with:
387+
deno-version: v2.x
388+
- uses: oven-sh/setup-bun@v2
369389
- name: JVM integration tests
370390
if: env.SHOULD_RUN == 'true'
371391
run: ./mill -i integration.test.jvm

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

Lines changed: 34 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -189,36 +189,31 @@ 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] =
192+
// The Scala.js Wasm backend compiles exceptions using the Wasm exnref proposal. Whether a runtime
193+
// needs --experimental-wasm-exnref is not predictable from its version: a throwing program runs
194+
// unflagged on Node 22.22 and 24 but crashes on Node 23.11 and Node 22.0, while Deno 2.8 removed the
195+
// flag entirely (passing it aborts). So we neither hardcode the flag nor detect versions; we ask the
196+
// runtime whether it accepts the flag and pass it only then - never withholding it where exceptions
197+
// need it, never passing it where it was removed. Bun runs on JavaScriptCore and needs no such flag.
198+
private val wasmExnrefFlag = "--experimental-wasm-exnref"
199+
200+
// Whether `command` (which passes the exnref flag) exits successfully, i.e. the runtime accepts it.
201+
private def acceptsWasmExnref(command: Seq[String], extraEnv: Map[String, String]): Boolean =
195202
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
203+
val builder = new ProcessBuilder(command*)
204+
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
205+
.redirectError(ProcessBuilder.Redirect.DISCARD)
206+
extraEnv.foreach { case (k, v) => builder.environment().put(k, v) }
207+
builder.start().waitFor() == 0
211208
}
209+
catch { case _: Exception => false }
212210

213-
// Pre-V8 13.x runtimes need --experimental-wasm-exnref for the Scala.js Wasm exception model.
214-
// V8 13.x ships in Node 25+ (Node 24 is still on V8 12.x where exnref is gated behind the flag).
215-
// In Node 26+, the flag may be removed from the CLI. Only pass it when Node < 25.
216-
// None.forall(_ < 25) == true — safe fallback when version detection fails.
217-
private def nodeNeedsWasmFlag: Boolean = nodeMajorVersion.forall(_ < 25)
211+
// Node takes the flag as a CLI arg; Deno takes it via the DENO_V8_FLAGS env var.
212+
private def nodeAcceptsWasmExnref(nodePath: String): Boolean =
213+
acceptsWasmExnref(Seq(nodePath, wasmExnrefFlag, "-e", ""), Map.empty)
218214

219-
// Deno 2.x bundles V8 12.x where wasm-exnref is gated behind a flag; symmetrical reasoning to Node.
220-
// We always set DENO_V8_FLAGS=--experimental-wasm-exnref on Wasm output until V8 13.x lands in Deno.
221-
private def denoNeedsWasmFlag: Boolean = true
215+
private def denoAcceptsWasmExnref(denoPath: String): Boolean =
216+
acceptsWasmExnref(Seq(denoPath, "eval", "0"), Map("DENO_V8_FLAGS" -> wasmExnrefFlag))
222217

223218
private def endsWithCaseInsensitive(s: String, suffix: String): Boolean =
224219
s.length >= suffix.length &&
@@ -257,7 +252,7 @@ object Runner {
257252
): Seq[String] = {
258253

259254
val nodePath = findInPath("node").fold("node")(_.toString)
260-
val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil
255+
val nodeFlags = if (emitWasm && nodeAcceptsWasmExnref(nodePath)) List(wasmExnrefFlag) else Nil
261256
val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
262257

263258
if (jsDom)
@@ -282,10 +277,10 @@ object Runner {
282277
value(findInPath("node")
283278
.map(_.toString)
284279
.toRight(NodeNotFoundError()))
285-
val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil
286-
if (emitWasm && nodeFlags.nonEmpty)
280+
val nodeFlags = if (emitWasm && nodeAcceptsWasmExnref(nodePath)) List(wasmExnrefFlag) else Nil
281+
if (nodeFlags.nonEmpty)
287282
logger.log(
288-
s"Wasm: adding ${nodeFlags.mkString(" ")} (required for Wasm exception handling on Node.js < 25)"
283+
s"Wasm: adding ${nodeFlags.mkString(" ")} to Node.js (required by the Scala.js Wasm backend for exception handling)"
289284
)
290285
if !jsDom && allowExecve && Execve.available() then {
291286
val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
@@ -367,18 +362,23 @@ object Runner {
367362
.map(_.toString)
368363
.toRight(DenoNotFoundError()))
369364
val denoFlags = Seq("run", "--allow-read")
370-
val wasmFlag = "--experimental-wasm-exnref"
371365
val extraEnv =
372-
if (emitWasm && denoNeedsWasmFlag) {
366+
if (emitWasm && denoAcceptsWasmExnref(denoPath)) {
373367
// Append to any existing DENO_V8_FLAGS rather than replacing them.
374368
val existing = sys.env.get("DENO_V8_FLAGS").filter(_.nonEmpty)
375-
val merged = existing.fold(wasmFlag)(f => s"$f $wasmFlag")
369+
val merged = existing.fold(wasmExnrefFlag)(f => s"$f $wasmExnrefFlag")
376370
logger.log(
377371
s"Wasm: setting DENO_V8_FLAGS=$merged (required for Wasm exception handling)"
378372
)
379373
Map("DENO_V8_FLAGS" -> merged)
380374
}
381-
else Map.empty[String, String]
375+
else {
376+
if (emitWasm)
377+
logger.debug(
378+
"Wasm: Deno does not accept --experimental-wasm-exnref; assuming exnref is enabled by default"
379+
)
380+
Map.empty[String, String]
381+
}
382382

383383
if (allowExecve && Execve.available()) {
384384
val command = Seq(denoPath) ++ denoFlags ++ Seq(entrypoint.getAbsolutePath) ++ args

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

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ 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, WasmRuntime}
21+
import scala.build.options.{BuildOptions, JSRuntime, JavaOpt, PackageType, Platform, Scope}
2222
import scala.cli.CurrentParams
2323
import scala.cli.commands.package0.Package
2424
import scala.cli.commands.setupide.SetupIde
@@ -478,9 +478,12 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
478478

479479
// Check if Wasm mode is requested
480480
if jsOpts.jsEmitWasm then {
481-
val runtime = jsOpts.wasmRuntime
481+
val runtime = jsOpts.jsRuntime
482482
val esModule = true // Wasm backend uses ES modules
483-
logger.log("Wasm mode enabled: using ES module output on JS platform")
483+
if (jsOpts.moduleKindStr.isEmpty)
484+
logger.log(
485+
"Wasm mode: ES module output is enabled implicitly (required by the Scala.js Wasm backend)"
486+
)
484487
scratchDirOpt.foreach(os.makeDir.all(_))
485488
val jsDest = os.temp(
486489
dir = scratchDirOpt.orNull,
@@ -504,25 +507,25 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
504507
).map { outputPath =>
505508
if showCommand then
506509
runtime match {
507-
case WasmRuntime.Deno =>
510+
case JSRuntime.Deno =>
508511
Left(Runner.denoCommand(outputPath.toIO, args))
509-
case WasmRuntime.Node =>
512+
case JSRuntime.Node =>
510513
Left(Runner.jsCommand(outputPath.toIO, args, jsDom = false, emitWasm = true))
511-
case WasmRuntime.Bun =>
514+
case JSRuntime.Bun =>
512515
Left(Runner.bunCommand(outputPath.toIO, args))
513516
}
514517
else {
515518
val process = value {
516519
runtime match {
517-
case WasmRuntime.Deno =>
520+
case JSRuntime.Deno =>
518521
Runner.runDeno(
519522
outputPath.toIO,
520523
args,
521524
logger,
522525
allowExecve = effectiveAllowExecve,
523526
emitWasm = true
524527
)
525-
case WasmRuntime.Node =>
528+
case JSRuntime.Node =>
526529
Runner.runJs(
527530
outputPath.toIO,
528531
args,
@@ -533,7 +536,7 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
533536
esModule = esModule,
534537
emitWasm = true
535538
)
536-
case WasmRuntime.Bun =>
539+
case JSRuntime.Bun =>
537540
Runner.runBun(
538541
outputPath.toIO,
539542
args,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ final case class ScalaJsOptions(
6464
@HelpMessage("Enable Wasm output (Scala.js Wasm backend). Uses Node.js by default. To show more options for Wasm pass `--help-wasm`")
6565
jsEmitWasm: Option[Boolean] = None,
6666

67-
@Group(HelpGroup.Wasm.toString)
67+
@Group(HelpGroup.ScalaJs.toString)
6868
@Tag(tags.experimental)
6969
@HelpMessage("Wasm runtime to use: node (default), deno, bun")
7070
jsWasmRuntime: Option[String] = None,

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -251,15 +251,15 @@ final case class SharedOptions(
251251
opts: ScalaJsOptions
252252
): Either[BuildException, options.ScalaJsOptions] = {
253253
import opts._
254-
val parsedWasmRuntime = jsWasmRuntime.fold(
255-
Right(options.WasmRuntime.default): Either[BuildException, options.WasmRuntime]
254+
val parsedJSRuntime = jsWasmRuntime.fold(
255+
Right(options.JSRuntime.default): Either[BuildException, options.JSRuntime]
256256
) { rt =>
257-
options.WasmRuntime.parse(rt).toRight {
258-
val validValues = options.WasmRuntime.all.map(_.name).mkString(", ")
259-
new scala.build.errors.UnrecognizedWasmRuntimeError(rt, validValues)
257+
options.JSRuntime.parse(rt).toRight {
258+
val validValues = options.JSRuntime.all.map(_.name).mkString(", ")
259+
new scala.build.errors.UnrecognizedJSRuntimeError(rt, validValues)
260260
}
261261
}
262-
parsedWasmRuntime.map(wasmRuntime =>
262+
parsedJSRuntime.map(jsRuntime =>
263263
options.ScalaJsOptions(
264264
version = jsVersion,
265265
mode = options.ScalaJsMode(jsMode),
@@ -279,7 +279,7 @@ final case class SharedOptions(
279279
remapEsModuleImportMap =
280280
jsEsModuleImportMap.filter(_.trim.nonEmpty).map(os.Path(_, Os.pwd)),
281281
jsEmitWasm = jsEmitWasm.getOrElse(false),
282-
wasmRuntime = wasmRuntime
282+
jsRuntime = jsRuntime
283283
)
284284
)
285285
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package scala.build.errors
2+
3+
class UnrecognizedJSRuntimeError(runtime: String, validValues: String)
4+
extends BuildException(s"Unrecognized JS runtime: '$runtime'. Valid values: $validValues")

modules/core/src/main/scala/scala/build/errors/UnrecognizedWasmRuntimeError.scala

Lines changed: 0 additions & 4 deletions
This file was deleted.

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ package scala.build.preprocessing.directives
22

33
import scala.build.Positioned
44
import scala.build.directives.*
5-
import scala.build.errors.{BuildException, UnrecognizedWasmRuntimeError}
6-
import scala.build.options.{BuildOptions, Platform, ScalaJsOptions, ScalaOptions, WasmRuntime}
5+
import scala.build.errors.{BuildException, UnrecognizedJSRuntimeError}
6+
import scala.build.options.{BuildOptions, JSRuntime, Platform, ScalaJsOptions, ScalaOptions}
77
import scala.cli.commands.SpecificationLevel
88

99
@DirectiveGroupName("Wasm options")
@@ -29,10 +29,10 @@ final case class Wasm(
2929
) extends HasBuildOptions {
3030
def buildOptions: Either[BuildException, BuildOptions] = {
3131
val parsedRuntime =
32-
wasmRuntime.fold(Right(WasmRuntime.default): Either[BuildException, WasmRuntime]) { rt =>
33-
WasmRuntime.parse(rt).toRight {
34-
val validValues = WasmRuntime.all.map(_.name).mkString(", ")
35-
new UnrecognizedWasmRuntimeError(rt, validValues)
32+
wasmRuntime.fold(Right(JSRuntime.default): Either[BuildException, JSRuntime]) { rt =>
33+
JSRuntime.parse(rt).toRight {
34+
val validValues = JSRuntime.all.map(_.name).mkString(", ")
35+
new UnrecognizedJSRuntimeError(rt, validValues)
3636
}
3737
}
3838
parsedRuntime.map { runtime =>
@@ -46,7 +46,7 @@ final case class Wasm(
4646
ScalaOptions()
4747
BuildOptions(
4848
scalaOptions = scalaOptions,
49-
scalaJsOptions = ScalaJsOptions(jsEmitWasm = wasmEnabled, wasmRuntime = runtime)
49+
scalaJsOptions = ScalaJsOptions(jsEmitWasm = wasmEnabled, jsRuntime = runtime)
5050
)
5151
}
5252
}

modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,49 @@ trait RunScalaJsTestDefinitions { this: RunTestDefinitions =>
476476
}
477477
}
478478

479+
// Exception handling exercises the Wasm exnref proposal, which older V8 gates behind a flag and
480+
// newer V8 has removed. A throwing program (unlike a plain println) actually needs exnref, so this
481+
// verifies it works on deno/bun (the node case is covered by "Wasm exception handling").
482+
def wasmExceptionHandlingTest(runtime: String): Unit = {
483+
val inputs = TestInputs(
484+
os.rel / "Hello.scala" ->
485+
"""object Hello {
486+
| def riskyOp(x: Int): Int =
487+
| if (x == 0) throw new IllegalArgumentException("zero!")
488+
| else 100 / x
489+
|
490+
| def main(args: Array[String]): Unit = {
491+
| val caught = try riskyOp(0).toString catch { case e: Exception => s"caught: ${e.getMessage}" }
492+
| println(caught)
493+
| }
494+
|}
495+
|""".stripMargin
496+
)
497+
inputs.fromRoot { root =>
498+
val output = os.proc(
499+
TestUtil.cli,
500+
"--power",
501+
"run",
502+
"Hello.scala",
503+
"--js-emit-wasm",
504+
"--js-wasm-runtime",
505+
runtime,
506+
extraOptions
507+
).call(cwd = root).out.trim()
508+
expect(output.linesIterator.toSeq.contains("caught: zero!"))
509+
}
510+
}
511+
512+
if (TestUtil.fromPath("deno").isDefined)
513+
test("Wasm exception handling on deno") {
514+
wasmExceptionHandlingTest("deno")
515+
}
516+
517+
if (TestUtil.fromPath("bun").isDefined)
518+
test("Wasm exception handling on bun") {
519+
wasmExceptionHandlingTest("bun")
520+
}
521+
479522
test("Wasm multiple source files") {
480523
val inputs = TestInputs(
481524
os.rel / "Greeter.scala" ->
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package scala.build.options
2+
3+
import java.util.Locale
4+
5+
/** JS-based runtimes for Scala.js Wasm backend execution. All embed a Wasm engine. */
6+
sealed abstract class JSRuntime(val name: String)
7+
8+
object JSRuntime {
9+
case object Node extends JSRuntime("node")
10+
case object Deno extends JSRuntime("deno")
11+
case object Bun extends JSRuntime("bun")
12+
13+
val all: Seq[JSRuntime] = Seq(Node, Deno, Bun)
14+
15+
def default: JSRuntime = Node
16+
17+
def parse(s: String): Option[JSRuntime] =
18+
s.trim.toLowerCase(Locale.ROOT) match {
19+
case "node" | "nodejs" => Some(Node)
20+
case "deno" => Some(Deno)
21+
case "bun" => Some(Bun)
22+
case _ => None
23+
}
24+
25+
implicit val hashedType: HashedType[JSRuntime] = runtime => runtime.name
26+
27+
implicit val hasHashData: HasHashData[JSRuntime] = HasHashData.asIs
28+
29+
implicit val monoid: ConfigMonoid[JSRuntime] = ConfigMonoid.instance[JSRuntime](default) {
30+
(a, b) => if (b == default) a else b
31+
}
32+
}

0 commit comments

Comments
 (0)