Skip to content

Commit b37dae0

Browse files
committed
Fix review feedback: Wasm capitalization, version-aware node flags, refactor into ScalaJsOptions
- Replace "WASM" with "Wasm" per WebAssembly spec: contraction, not acronym - Fix nodeNeedsWasmFlag to be version-aware: only pass --experimental-wasm-exnref for Node < 25 (V8 12.x); Node 25+ has it enabled by default, Node 26+ may remove it - Remove Node/Bun pre-flight version checks; let runtime fail naturally on old versions - Update else-if-emitWasm comment to more accurately explain why ProcessBuilder is used - Refactor WasmOptions into ScalaJsOptions: jsEmitWasm and wasmRuntime are now fields of ScalaJsOptions at both build and CLI layers; CLI flags are now --js-emit-wasm and --js-wasm-runtime under the Wasm help group; WasmOptions classes removed - linkerConfig() now forces ESModule when jsEmitWasm=true - Update all integration test CLI flags to --js-emit-wasm / --js-wasm-runtime - Regenerate reference docs
1 parent a0c6009 commit b37dae0

21 files changed

Lines changed: 142 additions & 236 deletions

File tree

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

Lines changed: 13 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -210,34 +210,16 @@ object Runner {
210210
case _: Exception => None
211211
}
212212

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-
// so until the default flips we always pass the flag when emitWasm is true.
216-
private def nodeNeedsWasmFlag: Boolean = true
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)
217218

218219
// Deno 2.x bundles V8 12.x where wasm-exnref is gated behind a flag; symmetrical reasoning to Node.
219-
// We always set DENO_V8_FLAGS=--experimental-wasm-exnref on emitWasm until V8 13.x lands in Deno.
220+
// We always set DENO_V8_FLAGS=--experimental-wasm-exnref on Wasm output until V8 13.x lands in Deno.
220221
private def denoNeedsWasmFlag: Boolean = true
221222

222-
// Detects the major version of Bun on PATH; cached for the JVM lifetime (lazy val).
223-
// Returns None if bun is not found or version cannot be parsed.
224-
private lazy val bunMajorVersion: Option[Int] =
225-
try {
226-
val process = new ProcessBuilder("bun", "--version")
227-
.redirectErrorStream(true)
228-
.start()
229-
val output = new String(process.getInputStream.readAllBytes()).trim
230-
process.waitFor()
231-
// Bun version format: "1.1.30" -> extract 1
232-
output.takeWhile(_.isDigit) match {
233-
case s if s.nonEmpty => Some(s.toInt)
234-
case _ => None
235-
}
236-
}
237-
catch {
238-
case _: Exception => None
239-
}
240-
241223
private def endsWithCaseInsensitive(s: String, suffix: String): Boolean =
242224
s.length >= suffix.length &&
243225
s.regionMatches(true, s.length - suffix.length, suffix, 0, suffix.length)
@@ -300,10 +282,6 @@ object Runner {
300282
value(findInPath("node")
301283
.map(_.toString)
302284
.toRight(NodeNotFoundError()))
303-
if (emitWasm)
304-
nodeMajorVersion.foreach { v =>
305-
if (v < 22) value(Left(new NodeVersionTooOldForWasmError(v)))
306-
}
307285
val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil
308286
if !jsDom && allowExecve && Execve.available() then {
309287
val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
@@ -323,8 +301,10 @@ object Runner {
323301
sys.error("should not happen")
324302
}
325303
else if (emitWasm) {
326-
// For WASM mode with ES modules, run node directly instead of NodeJSEnv.
327-
// NodeJSEnv's stdin piping with "-" doesn't work with Input.ESModule.
304+
// Wasm output always uses ESModule. NodeJSEnv prepends "-" to user args when they are
305+
// non-empty (to enable Node's stdin-pipe mode for Script inputs), but "-" before the
306+
// entrypoint tells Node to read from stdin instead of running the ESModule file.
307+
// Using ProcessBuilder directly places args after the entrypoint, which is correct.
328308
val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
329309

330310
logger.log(
@@ -333,7 +313,9 @@ object Runner {
333313
command.iterator.map(_ + System.lineSeparator()).mkString
334314
)
335315

336-
new ProcessBuilder(command: _*).inheritIO().start()
316+
new ProcessBuilder(command*)
317+
.inheritIO()
318+
.start()
337319
}
338320
else {
339321
val nodeArgs =
@@ -455,10 +437,6 @@ object Runner {
455437
value(findInPath("bun")
456438
.map(_.toString)
457439
.toRight(BunNotFoundError()))
458-
bunMajorVersion.foreach { v =>
459-
if (v < 1) value(Left(new BunVersionTooOldForWasmError(v)))
460-
}
461-
462440
val command = Seq(bunPath, "run", entrypoint.getAbsolutePath) ++ args
463441

464442
if (allowExecve && Execve.available()) {

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

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -474,12 +474,12 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
474474
if shouldLogCrossInfo then logger.debug(s"Running build for ${crossBuildParams.asString}")
475475
val build = builds.head
476476
either {
477-
val wasmOpts = build.options.wasmOptions
477+
val jsOpts = build.options.scalaJsOptions
478478

479-
// Check if WASM mode is requested
480-
if wasmOpts.enabled then {
481-
val runtime = wasmOpts.runtime
482-
val esModule = true // WASM backend uses ES modules
479+
// Check if Wasm mode is requested
480+
if jsOpts.jsEmitWasm then {
481+
val runtime = jsOpts.wasmRuntime
482+
val esModule = true // Wasm backend uses ES modules
483483
scratchDirOpt.foreach(os.makeDir.all(_))
484484
val jsDest = os.temp(
485485
dir = scratchDirOpt.orNull,
@@ -488,17 +488,16 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
488488
deleteOnExit = scratchDirOpt.isEmpty
489489
)
490490

491-
val linkerConfig = build.options.scalaJsOptions.linkerConfig(logger)
492-
.copy(emitWasm = true, moduleKind = ScalaJsLinkerConfig.ModuleKind.ESModule)
491+
val linkerConfig = jsOpts.linkerConfig(logger)
493492

494493
val res = Package.linkJs(
495494
builds = builds,
496495
dest = jsDest,
497496
mainClassOpt = Some(mainClass),
498497
addTestInitializer = false,
499498
config = linkerConfig,
500-
fullOpt = value(build.options.scalaJsOptions.fullOpt),
501-
noOpt = build.options.scalaJsOptions.noOpt.getOrElse(false),
499+
fullOpt = value(jsOpts.fullOpt),
500+
noOpt = jsOpts.noOpt.getOrElse(false),
502501
logger = logger,
503502
scratchDirOpt = scratchDirOpt
504503
).map { outputPath =>
@@ -529,7 +528,7 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
529528
logger,
530529
allowExecve = effectiveAllowExecve,
531530
jsDom = false,
532-
sourceMap = build.options.scalaJsOptions.emitSourceMaps,
531+
sourceMap = jsOpts.emitSourceMaps,
533532
esModule = esModule,
534533
emitWasm = true
535534
)

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,16 @@ final case class ScalaJsOptions(
5959
@HelpMessage("Enable jsdom")
6060
jsDom: Option[Boolean] = None,
6161

62-
@Group(HelpGroup.ScalaJs.toString)
62+
@Group(HelpGroup.Wasm.toString)
6363
@Tag(tags.experimental)
64-
@HelpMessage("Emit WASM")
64+
@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)
68+
@Tag(tags.experimental)
69+
@HelpMessage("Wasm runtime to use: node (default), deno, bun")
70+
jsWasmRuntime: Option[String] = None,
71+
6772
@Group(HelpGroup.ScalaJs.toString)
6873
@Tag(tags.should)
6974
@HelpMessage("A header that will be added at the top of generated .js files")

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

Lines changed: 38 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,6 @@ final case class SharedOptions(
5757
js: ScalaJsOptions = ScalaJsOptions(),
5858
@Recurse
5959
native: ScalaNativeOptions = ScalaNativeOptions(),
60-
@Recurse
61-
wasmOptions: WasmOptions = WasmOptions(),
6260
@Recurse
6361
compilationServer: SharedCompilationServerOptions = SharedCompilationServerOptions(),
6462
@Recurse
@@ -249,26 +247,40 @@ final case class SharedOptions(
249247
)
250248
.getOrElse(true)
251249

252-
private def scalaJsOptions(opts: ScalaJsOptions): options.ScalaJsOptions = {
250+
private def scalaJsOptions(
251+
opts: ScalaJsOptions
252+
): Either[BuildException, options.ScalaJsOptions] = {
253253
import opts._
254-
options.ScalaJsOptions(
255-
version = jsVersion,
256-
mode = options.ScalaJsMode(jsMode),
257-
moduleKindStr = jsModuleKind,
258-
checkIr = jsCheckIr,
259-
emitSourceMaps = jsEmitSourceMaps,
260-
sourceMapsDest = jsSourceMapsPath.filter(_.trim.nonEmpty).map(os.Path(_, Os.pwd)),
261-
dom = jsDom,
262-
header = jsHeader,
263-
allowBigIntsForLongs = jsAllowBigIntsForLongs,
264-
avoidClasses = jsAvoidClasses,
265-
avoidLetsAndConsts = jsAvoidLetsAndConsts,
266-
moduleSplitStyleStr = jsModuleSplitStyle,
267-
smallModuleForPackage = jsSmallModuleForPackage,
268-
esVersionStr = jsEsVersion,
269-
noOpt = jsNoOpt,
270-
remapEsModuleImportMap = jsEsModuleImportMap.filter(_.trim.nonEmpty).map(os.Path(_, Os.pwd)),
271-
jsEmitWasm = jsEmitWasm.getOrElse(false)
254+
val parsedWasmRuntime = jsWasmRuntime.fold(
255+
Right(options.WasmRuntime.default): Either[BuildException, options.WasmRuntime]
256+
) { rt =>
257+
options.WasmRuntime.parse(rt).toRight {
258+
val validValues = options.WasmRuntime.all.map(_.name).mkString(", ")
259+
new scala.build.errors.UnrecognizedWasmRuntimeError(rt, validValues)
260+
}
261+
}
262+
parsedWasmRuntime.map(wasmRuntime =>
263+
options.ScalaJsOptions(
264+
version = jsVersion,
265+
mode = options.ScalaJsMode(jsMode),
266+
moduleKindStr = jsModuleKind,
267+
checkIr = jsCheckIr,
268+
emitSourceMaps = jsEmitSourceMaps,
269+
sourceMapsDest = jsSourceMapsPath.filter(_.trim.nonEmpty).map(os.Path(_, Os.pwd)),
270+
dom = jsDom,
271+
header = jsHeader,
272+
allowBigIntsForLongs = jsAllowBigIntsForLongs,
273+
avoidClasses = jsAvoidClasses,
274+
avoidLetsAndConsts = jsAvoidLetsAndConsts,
275+
moduleSplitStyleStr = jsModuleSplitStyle,
276+
smallModuleForPackage = jsSmallModuleForPackage,
277+
esVersionStr = jsEsVersion,
278+
noOpt = jsNoOpt,
279+
remapEsModuleImportMap =
280+
jsEsModuleImportMap.filter(_.trim.nonEmpty).map(os.Path(_, Os.pwd)),
281+
jsEmitWasm = jsEmitWasm.getOrElse(false),
282+
wasmRuntime = wasmRuntime
283+
)
272284
)
273285
}
274286

@@ -313,26 +325,6 @@ final case class SharedOptions(
313325
)
314326
}
315327

316-
private def buildWasmOptions(
317-
opts: WasmOptions
318-
): Either[BuildException, options.WasmOptions] = {
319-
import opts._
320-
val wasmEnabled = wasm || wasmRuntime.isDefined
321-
val parsedRuntime: Either[BuildException, options.WasmRuntime] =
322-
wasmRuntime.fold(Right(options.WasmRuntime.default)) { rt =>
323-
options.WasmRuntime.parse(rt).toRight {
324-
val validValues = options.WasmRuntime.all.map(_.name).mkString(", ")
325-
new scala.build.errors.UnrecognizedWasmRuntimeError(rt, validValues)
326-
}
327-
}
328-
parsedRuntime.map(runtime =>
329-
options.WasmOptions(
330-
enabled = wasmEnabled,
331-
runtime = runtime
332-
)
333-
)
334-
}
335-
336328
lazy val scalacOptionsFromFiles: List[String] =
337329
scalac.argsFiles.flatMap(argFile =>
338330
ArgSplitter.splitToArgs(os.read(os.Path(argFile.file, os.pwd)))
@@ -357,8 +349,8 @@ final case class SharedOptions(
357349
case _ =>
358350
}
359351
val parsedPlatform = platform.map(Platform.normalize).flatMap(Platform.parse)
360-
// WASM mode requires Scala.js platform for compilation
361-
val wasmEnabled = wasmOptions.wasm || wasmOptions.wasmRuntime.isDefined
352+
// Wasm mode requires Scala.js platform for compilation
353+
val wasmEnabled = js.jsEmitWasm.getOrElse(false) || js.jsWasmRuntime.isDefined
362354
val platformOpt = value {
363355
(parsedPlatform, js.js, native.native, wasmEnabled) match {
364356
case (Some(p: Platform.JS.type), _, false, _) => Right(Some(p))
@@ -372,10 +364,10 @@ final case class SharedOptions(
372364
case (_, true, true, _) =>
373365
Left(new AmbiguousPlatformError(Seq(Platform.JS.toString, Platform.Native.toString)))
374366
case (_, _, true, true) =>
375-
Left(new AmbiguousPlatformError(Seq(Platform.Native.toString, "WASM (requires JS)")))
367+
Left(new AmbiguousPlatformError(Seq(Platform.Native.toString, "Wasm (requires JS)")))
376368
case (_, true, _, _) => Right(Some(Platform.JS))
377369
case (_, _, _, true) =>
378-
Right(Some(Platform.JS)) // WASM requires JS compilation (Scala.js WASM backend)
370+
Right(Some(Platform.JS)) // Wasm requires JS compilation (Scala.js Wasm backend)
379371
case (_, _, true, _) => Right(Some(Platform.Native))
380372
case _ => Right(None)
381373
}
@@ -462,9 +454,8 @@ final case class SharedOptions(
462454
scriptOptions = scala.build.options.ScriptOptions(
463455
forceObjectWrapper = objectWrapper
464456
),
465-
scalaJsOptions = scalaJsOptions(js),
457+
scalaJsOptions = value(scalaJsOptions(js)),
466458
scalaNativeOptions = snOpts,
467-
wasmOptions = value(buildWasmOptions(wasmOptions)),
468459
javaOptions = value(scala.cli.commands.util.JvmUtils.javaOptions(jvm)),
469460
jmhOptions = scala.build.options.JmhOptions(
470461
jmhVersion = benchmarking.jmhVersion,

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

Lines changed: 0 additions & 27 deletions
This file was deleted.
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 --wasm-runtime node"
4+
"Bun was not found on the PATH. Install Bun from https://bun.sh/ or use --js-wasm-runtime node"
55
)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ package scala.build.errors
22

33
final class BunVersionTooOldForWasmError(found: Int)
44
extends BuildException(
5-
s"Scala.js WASM backend requires Bun >= 1, but found Bun $found. " +
6-
"Upgrade Bun (https://bun.sh/) or switch runtime via --wasm-runtime node|deno."
5+
s"Scala.js Wasm backend requires Bun >= 1, but found Bun $found. " +
6+
"Upgrade Bun (https://bun.sh/) or switch runtime via --js-wasm-runtime node|deno."
77
)
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 --wasm-runtime node"
4+
"Deno was not found on the PATH. Install Deno from https://deno.land/ or use --js-wasm-runtime node"
55
)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ package scala.build.errors
22

33
final class NodeVersionTooOldForWasmError(found: Int)
44
extends BuildException(
5-
s"Scala.js WASM backend requires Node.js >= 22, but found Node.js $found. " +
6-
"Upgrade Node (https://nodejs.org/) or switch runtime via --wasm-runtime deno|bun."
5+
s"Scala.js Wasm backend requires Node.js >= 22, but found Node.js $found. " +
6+
"Upgrade Node (https://nodejs.org/) or switch runtime via --js-wasm-runtime deno|bun."
77
)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
package scala.build.errors
22

33
class UnrecognizedWasmRuntimeError(runtime: String, validValues: String)
4-
extends BuildException(s"Unrecognized WASM runtime: '$runtime'. Valid values: $validValues")
4+
extends BuildException(s"Unrecognized Wasm runtime: '$runtime'. Valid values: $validValues")

0 commit comments

Comments
 (0)