Skip to content

Commit c75c0c8

Browse files
committed
Wasm: always inject exnref, rename to JSRuntime, test Deno/Bun on CI, add guide
- Rename WasmRuntime -> JSRuntime and move --js-wasm-runtime into the JS option group - Always pass --experimental-wasm-exnref on Node in Wasm mode instead of gating on the Node version. The flag is documented as always required by the Scala.js Wasm backend and is accepted on every supported Node (22-25): required where exnref is still gated, a no-op where it's on by default. The old `<25` gate coupled to runtime versions and rested on a wrong comment (Node 24 is V8 13.6, not 12.x). Symmetric with Deno; logged. - 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
1 parent eabfdbe commit c75c0c8

12 files changed

Lines changed: 197 additions & 105 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: 16 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -189,36 +189,16 @@ 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-
// 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)
218-
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
192+
// The Scala.js Wasm backend needs --experimental-wasm-exnref for its exception model. We inject it
193+
// implicitly in Wasm mode (and log it) on every JS runtime that hosts the output (Node.js, Deno),
194+
// rather than detecting runtime versions. The Scala.js docs list it as always required
195+
// (https://www.scala-js.org/doc/project/webassembly.html), and it is accepted on every supported
196+
// version: required where exnref is still gated (e.g. Node.js 22-24) and a harmless no-op where it
197+
// is already on by default (Node.js 25+). Always passing it avoids tracking the exact version where
198+
// the default flips. WasmGC and typed function-references are on by default on supported runtimes;
199+
// optional flags (--experimental-wasm-jspi for js.async/js.await, --experimental-wasm-imported-strings
200+
// for performance) are left to the user via NODE_OPTIONS / DENO_V8_FLAGS.
201+
private val wasmExnrefFlag = "--experimental-wasm-exnref"
222202

223203
private def endsWithCaseInsensitive(s: String, suffix: String): Boolean =
224204
s.length >= suffix.length &&
@@ -257,7 +237,7 @@ object Runner {
257237
): Seq[String] = {
258238

259239
val nodePath = findInPath("node").fold("node")(_.toString)
260-
val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil
240+
val nodeFlags = if (emitWasm) List(wasmExnrefFlag) else Nil
261241
val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
262242

263243
if (jsDom)
@@ -282,10 +262,10 @@ object Runner {
282262
value(findInPath("node")
283263
.map(_.toString)
284264
.toRight(NodeNotFoundError()))
285-
val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil
286-
if (emitWasm && nodeFlags.nonEmpty)
265+
val nodeFlags = if (emitWasm) List(wasmExnrefFlag) else Nil
266+
if (nodeFlags.nonEmpty)
287267
logger.log(
288-
s"Wasm: adding ${nodeFlags.mkString(" ")} (required for Wasm exception handling on Node.js < 25)"
268+
s"Wasm: adding ${nodeFlags.mkString(" ")} to Node.js (required by the Scala.js Wasm backend for exception handling)"
289269
)
290270
if !jsDom && allowExecve && Execve.available() then {
291271
val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
@@ -367,12 +347,11 @@ object Runner {
367347
.map(_.toString)
368348
.toRight(DenoNotFoundError()))
369349
val denoFlags = Seq("run", "--allow-read")
370-
val wasmFlag = "--experimental-wasm-exnref"
371350
val extraEnv =
372-
if (emitWasm && denoNeedsWasmFlag) {
351+
if (emitWasm) {
373352
// Append to any existing DENO_V8_FLAGS rather than replacing them.
374353
val existing = sys.env.get("DENO_V8_FLAGS").filter(_.nonEmpty)
375-
val merged = existing.fold(wasmFlag)(f => s"$f $wasmFlag")
354+
val merged = existing.fold(wasmExnrefFlag)(f => s"$f $wasmExnrefFlag")
376355
logger.log(
377356
s"Wasm: setting DENO_V8_FLAGS=$merged (required for Wasm exception handling)"
378357
)

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
}
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+
}

modules/options/src/main/scala/scala/build/options/ScalaJsOptions.scala

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ final case class ScalaJsOptions(
2727
esVersionStr: Option[String] = None,
2828
noOpt: Option[Boolean] = None,
2929
jsEmitWasm: Boolean = false,
30-
wasmRuntime: WasmRuntime = WasmRuntime.default
30+
jsRuntime: JSRuntime = JSRuntime.default
3131
) {
3232
def fullOpt: Either[UnrecognizedJsOptModeError, Boolean] =
3333
if (mode.isValid)
@@ -155,7 +155,12 @@ final case class ScalaJsOptions(
155155
esVersion = esVersion(logger)
156156
)
157157

158-
if (jsEmitWasm && moduleKindStr.isDefined)
158+
val overridesWasmModuleKind =
159+
moduleKindStr.exists { k =>
160+
val normalized = k.trim.toLowerCase(Locale.ROOT)
161+
normalized != "es" && normalized != "esmodule"
162+
}
163+
if (jsEmitWasm && overridesWasmModuleKind)
159164
logger.message(
160165
s"[${Console.YELLOW}warn${Console.RESET}] Wasm mode forces ES module output; --js-module-kind is ignored"
161166
)

0 commit comments

Comments
 (0)