Skip to content

Commit 6c6eb57

Browse files
committed
Wasm: drop implicit flag injection, require explicit ES module, rename to --js-runtime
- Remove the exnref capability probe and all --experimental-wasm-exnref / DENO_V8_FLAGS injection (tanishiking). scala-cli no longer manages V8 flags; on older runtimes that still gate exnref, users pass it via NODE_OPTIONS / DENO_V8_FLAGS. CI runtimes (node 24, deno 2.x, bun) need none. - Stop forcing ESModule for Wasm; fail fast with WasmModuleKindError when --js-module-kind isn't es, instead of silently overriding it. Keeps config consistent with Scala.js and future module kinds. - Rename --js-wasm-runtime -> --js-runtime and //> using wasmRuntime -> //> using jsRuntime; selecting a runtime no longer auto-enables Wasm. - Update integration tests (explicit --js-module-kind es, renamed flag, fail-fast test) and the Wasm guide; regenerate reference docs.
1 parent eabfdbe commit 6c6eb57

18 files changed

Lines changed: 332 additions & 173 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: 8 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -189,37 +189,6 @@ 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
222-
223192
private def endsWithCaseInsensitive(s: String, suffix: String): Boolean =
224193
s.length >= suffix.length &&
225194
s.regionMatches(true, s.length - suffix.length, suffix, 0, suffix.length)
@@ -252,13 +221,11 @@ object Runner {
252221
def jsCommand(
253222
entrypoint: File,
254223
args: Seq[String],
255-
jsDom: Boolean = false,
256-
emitWasm: Boolean = false
224+
jsDom: Boolean = false
257225
): Seq[String] = {
258226

259-
val nodePath = findInPath("node").fold("node")(_.toString)
260-
val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil
261-
val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
227+
val nodePath = findInPath("node").fold("node")(_.toString)
228+
val command = Seq(nodePath) ++ Seq(entrypoint.getAbsolutePath) ++ args
262229

263230
if (jsDom)
264231
// FIXME We'd need to replicate what JSDOMNodeJSEnv does under-the-hood to get the command in that case.
@@ -275,20 +242,14 @@ object Runner {
275242
allowExecve: Boolean = false,
276243
jsDom: Boolean = false,
277244
sourceMap: Boolean = false,
278-
esModule: Boolean = false,
279-
emitWasm: Boolean = false
245+
esModule: Boolean = false
280246
): Either[BuildException, Process] = either {
281247
val nodePath: String =
282248
value(findInPath("node")
283249
.map(_.toString)
284250
.toRight(NodeNotFoundError()))
285-
val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil
286-
if (emitWasm && nodeFlags.nonEmpty)
287-
logger.log(
288-
s"Wasm: adding ${nodeFlags.mkString(" ")} (required for Wasm exception handling on Node.js < 25)"
289-
)
290251
if !jsDom && allowExecve && Execve.available() then {
291-
val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
252+
val command = Seq(nodePath) ++ Seq(entrypoint.getAbsolutePath) ++ args
292253

293254
logger.log(
294255
s"Running ${command.mkString(" ")}",
@@ -309,7 +270,7 @@ object Runner {
309270
// Scala.js runs apps by piping JS to node.
310271
// If we need to pass arguments, we must first make the piped input explicit
311272
// with "-", and we pass the user's arguments after that.
312-
nodeFlags ++ (if args.isEmpty then Nil else "-" :: args.toList)
273+
if args.isEmpty then Nil else "-" :: args.toList
313274
val envJs =
314275
if jsDom then
315276
new JSDOMNodeJSEnv(
@@ -359,26 +320,13 @@ object Runner {
359320
entrypoint: File,
360321
args: Seq[String],
361322
logger: Logger,
362-
allowExecve: Boolean = false,
363-
emitWasm: Boolean = false
323+
allowExecve: Boolean = false
364324
): Either[BuildException, Process] = either {
365325
val denoPath: String =
366326
value(findInPath("deno")
367327
.map(_.toString)
368328
.toRight(DenoNotFoundError()))
369329
val denoFlags = Seq("run", "--allow-read")
370-
val wasmFlag = "--experimental-wasm-exnref"
371-
val extraEnv =
372-
if (emitWasm && denoNeedsWasmFlag) {
373-
// Append to any existing DENO_V8_FLAGS rather than replacing them.
374-
val existing = sys.env.get("DENO_V8_FLAGS").filter(_.nonEmpty)
375-
val merged = existing.fold(wasmFlag)(f => s"$f $wasmFlag")
376-
logger.log(
377-
s"Wasm: setting DENO_V8_FLAGS=$merged (required for Wasm exception handling)"
378-
)
379-
Map("DENO_V8_FLAGS" -> merged)
380-
}
381-
else Map.empty[String, String]
382330

383331
if (allowExecve && Execve.available()) {
384332
val command = Seq(denoPath) ++ denoFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
@@ -393,7 +341,7 @@ object Runner {
393341
Execve.execve(
394342
command.head,
395343
"deno" +: command.tail.toArray,
396-
(sys.env ++ extraEnv).toArray.sorted.map { case (k, v) => s"$k=$v" }
344+
sys.env.toArray.sorted.map { case (k, v) => s"$k=$v" }
397345
)
398346
sys.error("should not happen")
399347
}
@@ -408,9 +356,6 @@ object Runner {
408356

409357
val builder = new ProcessBuilder(command*)
410358
.inheritIO()
411-
val env = builder.environment()
412-
for ((k, v) <- extraEnv)
413-
env.put(k, v)
414359
builder.start()
415360
}
416361
}

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

Lines changed: 14 additions & 15 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,10 @@ 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
482-
val esModule = true // Wasm backend uses ES modules
483-
logger.log("Wasm mode enabled: using ES module output on JS platform")
481+
// The Scala.js Wasm backend can only emit ES modules; require it explicitly.
482+
value(jsOpts.validateWasm)
483+
val runtime = jsOpts.jsRuntime
484+
val esModule = true // guaranteed by validateWasm above
484485
scratchDirOpt.foreach(os.makeDir.all(_))
485486
val jsDest = os.temp(
486487
dir = scratchDirOpt.orNull,
@@ -504,36 +505,34 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
504505
).map { outputPath =>
505506
if showCommand then
506507
runtime match {
507-
case WasmRuntime.Deno =>
508+
case JSRuntime.Deno =>
508509
Left(Runner.denoCommand(outputPath.toIO, args))
509-
case WasmRuntime.Node =>
510-
Left(Runner.jsCommand(outputPath.toIO, args, jsDom = false, emitWasm = true))
511-
case WasmRuntime.Bun =>
510+
case JSRuntime.Node =>
511+
Left(Runner.jsCommand(outputPath.toIO, args, jsDom = false))
512+
case JSRuntime.Bun =>
512513
Left(Runner.bunCommand(outputPath.toIO, args))
513514
}
514515
else {
515516
val process = value {
516517
runtime match {
517-
case WasmRuntime.Deno =>
518+
case JSRuntime.Deno =>
518519
Runner.runDeno(
519520
outputPath.toIO,
520521
args,
521522
logger,
522-
allowExecve = effectiveAllowExecve,
523-
emitWasm = true
523+
allowExecve = effectiveAllowExecve
524524
)
525-
case WasmRuntime.Node =>
525+
case JSRuntime.Node =>
526526
Runner.runJs(
527527
outputPath.toIO,
528528
args,
529529
logger,
530530
allowExecve = effectiveAllowExecve,
531531
jsDom = false,
532532
sourceMap = jsOpts.emitSourceMaps,
533-
esModule = esModule,
534-
emitWasm = true
533+
esModule = esModule
535534
)
536-
case WasmRuntime.Bun =>
535+
case JSRuntime.Bun =>
537536
Runner.runBun(
538537
outputPath.toIO,
539538
args,

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,10 @@ 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)
69-
@HelpMessage("Wasm runtime to use: node (default), deno, bun")
70-
jsWasmRuntime: Option[String] = None,
69+
@HelpMessage("JS runtime to run the output on: node (default), deno, bun")
70+
jsRuntime: Option[String] = None,
7171

7272
@Group(HelpGroup.ScalaJs.toString)
7373
@Tag(tags.should)

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

Lines changed: 10 additions & 9 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 = jsRuntime.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(parsedRuntime =>
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 = parsedRuntime
283283
)
284284
)
285285
}
@@ -349,8 +349,9 @@ final case class SharedOptions(
349349
case _ =>
350350
}
351351
val parsedPlatform = platform.map(Platform.normalize).flatMap(Platform.parse)
352-
// Wasm mode requires Scala.js platform for compilation
353-
val wasmEnabled = js.jsEmitWasm.getOrElse(false) || js.jsWasmRuntime.isDefined
352+
// Wasm mode requires Scala.js platform for compilation. Selecting a JS runtime
353+
// (--js-runtime) does not by itself enable Wasm.
354+
val wasmEnabled = js.jsEmitWasm.getOrElse(false)
354355
val platformOpt = value {
355356
(parsedPlatform, js.js, native.native, wasmEnabled) match {
356357
case (Some(p: Platform.JS.type), _, false, _) => Right(Some(p))
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
package scala.build.errors
22

33
final class BunNotFoundError extends BuildException(
4-
"Bun was not found on the PATH. Install Bun from https://bun.sh/ or use --js-wasm-runtime node"
4+
"Bun was not found on the PATH. Install Bun from https://bun.sh/ or use --js-runtime node"
55
)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
package scala.build.errors
22

33
final class DenoNotFoundError extends BuildException(
4-
"Deno was not found on the PATH. Install Deno from https://deno.land/ or use --js-wasm-runtime node"
4+
"Deno was not found on the PATH. Install Deno from https://deno.land/ or use --js-runtime node"
55
)
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.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package scala.build.errors
2+
3+
class WasmModuleKindError
4+
extends BuildException(
5+
"Wasm output requires ES modules. Pass --js-module-kind es (or add //> using jsModuleKind es)."
6+
)

0 commit comments

Comments
 (0)