Skip to content
Open
51 changes: 51 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ jobs:
if: env.SHOULD_RUN == 'true'
with:
jvm: "temurin:17"
- uses: actions/setup-node@v6
with:
node-version: 24
- name: JVM integration tests
if: env.SHOULD_RUN == 'true'
run: ./mill -i integration.test.jvmBootstrapped
Expand Down Expand Up @@ -196,6 +199,9 @@ jobs:
if: env.SHOULD_RUN == 'true'
with:
jvm: "temurin:17"
- uses: actions/setup-node@v6
with:
node-version: 24
- name: JVM integration tests
if: env.SHOULD_RUN == 'true'
run: ./mill -i integration.test.jvm
Expand Down Expand Up @@ -234,6 +240,9 @@ jobs:
if: env.SHOULD_RUN == 'true'
with:
jvm: "temurin:17"
- uses: actions/setup-node@v6
with:
node-version: 24
- name: JVM integration tests
if: env.SHOULD_RUN == 'true'
run: ./mill -i integration.test.jvm
Expand Down Expand Up @@ -272,6 +281,9 @@ jobs:
if: env.SHOULD_RUN == 'true'
with:
jvm: "temurin:17"
- uses: actions/setup-node@v6
with:
node-version: 24
- name: JVM integration tests
if: env.SHOULD_RUN == 'true'
run: ./mill -i integration.test.jvm
Expand Down Expand Up @@ -310,6 +322,9 @@ jobs:
if: env.SHOULD_RUN == 'true'
with:
jvm: "temurin:17"
- uses: actions/setup-node@v6
with:
node-version: 24
- name: JVM integration tests
if: env.SHOULD_RUN == 'true'
run: ./mill -i integration.test.jvm
Expand Down Expand Up @@ -348,6 +363,9 @@ jobs:
if: env.SHOULD_RUN == 'true'
with:
jvm: "temurin:17"
- uses: actions/setup-node@v6
with:
node-version: 24
- name: JVM integration tests
if: env.SHOULD_RUN == 'true'
run: ./mill -i integration.test.jvm
Expand Down Expand Up @@ -436,6 +454,9 @@ jobs:
with:
name: linux-launchers
path: artifacts/
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Native integration tests
if: env.SHOULD_RUN == 'true'
run: ./mill -i nativeIntegrationTests
Expand Down Expand Up @@ -482,6 +503,9 @@ jobs:
with:
name: linux-launchers
path: artifacts/
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Native integration tests
if: env.SHOULD_RUN == 'true'
run: ./mill -i nativeIntegrationTests
Expand Down Expand Up @@ -528,6 +552,9 @@ jobs:
with:
name: linux-launchers
path: artifacts/
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Native integration tests
if: env.SHOULD_RUN == 'true'
run: ./mill -i nativeIntegrationTests
Expand Down Expand Up @@ -574,6 +601,9 @@ jobs:
with:
name: linux-launchers
path: artifacts/
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Native integration tests
if: env.SHOULD_RUN == 'true'
run: ./mill -i nativeIntegrationTests
Expand Down Expand Up @@ -620,6 +650,9 @@ jobs:
with:
name: linux-launchers
path: artifacts/
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Native integration tests
if: env.SHOULD_RUN == 'true'
run: ./mill -i nativeIntegrationTests
Expand Down Expand Up @@ -713,6 +746,9 @@ jobs:
with:
name: linux-aarch64-launchers
path: artifacts/
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Native integration tests
if: env.SHOULD_RUN == 'true'
run: ./mill -i nativeIntegrationTests
Expand Down Expand Up @@ -759,6 +795,9 @@ jobs:
with:
name: linux-aarch64-launchers
path: artifacts/
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Native integration tests
if: env.SHOULD_RUN == 'true'
run: ./mill -i nativeIntegrationTests
Expand Down Expand Up @@ -1417,6 +1456,9 @@ jobs:
- name: Build slim docker image
if: env.SHOULD_RUN == 'true'
run: .github/scripts/generate-slim-docker-image.sh
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Native integration tests
if: env.SHOULD_RUN == 'true'
run: ./mill -i integration.test.nativeMostlyStatic
Expand Down Expand Up @@ -1475,6 +1517,9 @@ jobs:
with:
name: mostly-static-launchers
path: artifacts/
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Native integration tests
if: env.SHOULD_RUN == 'true'
run: ./mill -i integration.test.nativeMostlyStatic
Expand Down Expand Up @@ -1563,6 +1608,9 @@ jobs:
- name: Build docker image
if: env.SHOULD_RUN == 'true'
run: .github/scripts/generate-docker-image.sh
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Native integration tests
if: env.SHOULD_RUN == 'true'
run: ./mill -i integration.test.nativeStatic
Expand Down Expand Up @@ -1624,6 +1672,9 @@ jobs:
- name: Build docker image
if: env.SHOULD_RUN == 'true'
run: .github/scripts/generate-docker-image.sh
- uses: actions/setup-node@v6
with:
node-version: 24
- name: Native integration tests
if: env.SHOULD_RUN == 'true'
run: ./mill -i integration.test.nativeStatic
Expand Down
168 changes: 162 additions & 6 deletions modules/build/src/main/scala/scala/build/internal/Runner.scala
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,37 @@ object Runner {
run(command, logger, cwd = cwd, extraEnv = extraEnv)
}

// Detects the major version of Node.js on PATH; cached for the JVM lifetime (lazy val).
// Returns None if node is not found or version cannot be parsed.
private lazy val nodeMajorVersion: Option[Int] =
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about scala-cli policy, but I feel like detecting node version if it supports wasm or not is too much.

my 2 cents: scala-cli should loosely couple with the runtime environment, just try to run and let them fail if it's too old.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed it, so scala cli lets runtime fail if it is too old

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see it's removed, did you forget to push some commits?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

likewise, unresolved?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies for the earlier confusion — I said "removed" but hadn't actually pushed the change. In the end I kept the version detection, because @Gedochao's comment below explicitly approved the approach:
"We can add some options as implicit if user doesn't specify them and we know they are necessary to run the Wasm build. Just make sure the implicit stuff is logged."

The current state: nodeMajorVersion remains, --experimental-wasm-exnref is passed only on Node < 25 (where V8 12.x requires it), and when it is injected, Scala CLI now logs:
"Wasm: adding --experimental-wasm-exnref (required for Wasm exception handling on Node.js < 25)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(While I personally don't like scala-cli manage options implicitly), if we do that, we should properly append all necessary flags for GC and EH across every version of deno and Node. Why don't we automatically append options for Node 22, 23, and 24? and how about every versions of Deno?

Also, do I understand correctly that, we only manage the minimum required flags for execution (exnref, gc, function-references)? I mean, how about other features such as js-string-builtins, JSPI, (and custom descriptor in future?) Are those flags are expected to be added by users manually through NODE_OPTIONS and DENO_V8_FLAGS ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that sounds like quite the headache to manage implicitly...
to chip in - doing some of this for the user is fine, as long as it is clear what needs to be passed manually, and what has been picked up implicitly.
the feature is meant to be experimental, so it's okay to require some knowledge from the user (although if we can provide a seamless UX, that's great)

try {
val process = new ProcessBuilder("node", "--version")
.redirectErrorStream(true)
.start()
val output = new String(process.getInputStream.readAllBytes()).trim
process.waitFor()
// Node version format: "v22.5.0" -> extract 22
if (output.startsWith("v"))
output.drop(1).takeWhile(_.isDigit) match {
case s if s.nonEmpty => Some(s.toInt)
case _ => None
}
else None
}
catch {
case _: Exception => None
}

// Pre-V8 13.x runtimes need --experimental-wasm-exnref for the Scala.js Wasm exception model.
// V8 13.x ships in Node 25+ (Node 24 is still on V8 12.x where exnref is gated behind the flag).
// In Node 26+, the flag may be removed from the CLI. Only pass it when Node < 25.
// None.forall(_ < 25) == true — safe fallback when version detection fails.
private def nodeNeedsWasmFlag: Boolean = nodeMajorVersion.forall(_ < 25)

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

private def endsWithCaseInsensitive(s: String, suffix: String): Boolean =
s.length >= suffix.length &&
s.regionMatches(true, s.length - suffix.length, suffix, 0, suffix.length)
Expand Down Expand Up @@ -221,11 +252,13 @@ object Runner {
def jsCommand(
entrypoint: File,
args: Seq[String],
jsDom: Boolean = false
jsDom: Boolean = false,
emitWasm: Boolean = false
): Seq[String] = {

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

if (jsDom)
// FIXME We'd need to replicate what JSDOMNodeJSEnv does under-the-hood to get the command in that case.
Expand All @@ -242,14 +275,20 @@ object Runner {
allowExecve: Boolean = false,
jsDom: Boolean = false,
sourceMap: Boolean = false,
esModule: Boolean = false
esModule: Boolean = false,
emitWasm: Boolean = false
): Either[BuildException, Process] = either {
val nodePath: String =
value(findInPath("node")
.map(_.toString)
.toRight(NodeNotFoundError()))
val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil
Copy link
Copy Markdown

@tanishiking tanishiking May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think tools like scala-cli to hardcode Node options, and instead, let users explicitly specify Node options by themselves something like like: --node-args=--experimental-wasm-exnref,--experimental-wasm-imported-strings ?

in Node 26, options like --experimental-wasm-imported-strings is removed, and --experimental-wasm-exnref is now enabled by default (and may eventually be removed as well). If scala-cli hardcode options, we'll be in trouble when underlying runtime (node) removes options.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. nodeNeedsWasmFlag is now version-aware: private def nodeNeedsWasmFlag: Boolean = nodeMajorVersion.forall(_ < 25)

Copy link
Copy Markdown

@tanishiking tanishiking May 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah sorry, --experimental-wasm-exnref being unnecessary in Node 26+ was just an example. The point was that options passed to Node shouldn't be hardcoded on the scala-cli side. (current implementation is fine since it doesn't add invalid options to node though)

Whether to detect the Node version and automatically add options should follow scala-cli's implementation policy. :) FYI @Gedochao


I was thinking about passing Node options from the scala-cli side like --node-args=--experimental-wasm-exnref,--experimental-wasm-imported-strings, but there's NODE_OPTIONS environment variable. Nevermind! 😄

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add some options as implicit if user doesn't specify them and we know they are necessary to run the Wasm build. Just make sure the implicit stuff is logged, so that the user knows what's happening.

if (emitWasm && nodeFlags.nonEmpty)
logger.log(
s"Wasm: adding ${nodeFlags.mkString(" ")} (required for Wasm exception handling on Node.js < 25)"
)
if !jsDom && allowExecve && Execve.available() then {
val command = Seq(nodePath, entrypoint.getAbsolutePath) ++ args
val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args

logger.log(
s"Running ${command.mkString(" ")}",
Expand All @@ -270,7 +309,7 @@ object Runner {
// Scala.js runs apps by piping JS to node.
// If we need to pass arguments, we must first make the piped input explicit
// with "-", and we pass the user's arguments after that.
if args.isEmpty then Nil else "-" :: args.toList
nodeFlags ++ (if args.isEmpty then Nil else "-" :: args.toList)
val envJs =
if jsDom then
new JSDOMNodeJSEnv(
Expand Down Expand Up @@ -307,6 +346,123 @@ object Runner {
}
}

def denoCommand(
entrypoint: File,
args: Seq[String]
): Seq[String] = {
val denoPath = findInPath("deno").fold("deno")(_.toString)
val denoFlags = Seq("run", "--allow-read")
Seq(denoPath) ++ denoFlags ++ Seq(entrypoint.getAbsolutePath) ++ args
}

def runDeno(
entrypoint: File,
args: Seq[String],
logger: Logger,
allowExecve: Boolean = false,
emitWasm: Boolean = false
): Either[BuildException, Process] = either {
val denoPath: String =
value(findInPath("deno")
.map(_.toString)
.toRight(DenoNotFoundError()))
val denoFlags = Seq("run", "--allow-read")
val wasmFlag = "--experimental-wasm-exnref"
val extraEnv =
if (emitWasm && denoNeedsWasmFlag) {
// Append to any existing DENO_V8_FLAGS rather than replacing them.
val existing = sys.env.get("DENO_V8_FLAGS").filter(_.nonEmpty)
val merged = existing.fold(wasmFlag)(f => s"$f $wasmFlag")
logger.log(
s"Wasm: setting DENO_V8_FLAGS=$merged (required for Wasm exception handling)"
)
Map("DENO_V8_FLAGS" -> merged)
}
else Map.empty[String, String]

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

logger.log(
s"Running ${command.mkString(" ")}",
" Running" + System.lineSeparator() +
command.iterator.map(_ + System.lineSeparator()).mkString
)

logger.debug("execve available")
Execve.execve(
command.head,
"deno" +: command.tail.toArray,
(sys.env ++ extraEnv).toArray.sorted.map { case (k, v) => s"$k=$v" }
)
sys.error("should not happen")
}
else {
val command = Seq(denoPath) ++ denoFlags ++ Seq(entrypoint.getAbsolutePath) ++ args

logger.log(
s"Running ${command.mkString(" ")}",
" Running" + System.lineSeparator() +
command.iterator.map(_ + System.lineSeparator()).mkString
)

val builder = new ProcessBuilder(command*)
.inheritIO()
val env = builder.environment()
for ((k, v) <- extraEnv)
env.put(k, v)
builder.start()
}
}

def bunCommand(
entrypoint: File,
args: Seq[String]
): Seq[String] = {
val bunPath = findInPath("bun").fold("bun")(_.toString)
Seq(bunPath, "run", entrypoint.getAbsolutePath) ++ args
}

def runBun(
entrypoint: File,
args: Seq[String],
logger: Logger,
allowExecve: Boolean = false
): Either[BuildException, Process] = either {
val bunPath: String =
value(findInPath("bun")
.map(_.toString)
.toRight(BunNotFoundError()))
val command = Seq(bunPath, "run", entrypoint.getAbsolutePath) ++ args

if (allowExecve && Execve.available()) {
logger.log(
s"Running ${command.mkString(" ")}",
" Running" + System.lineSeparator() +
command.iterator.map(_ + System.lineSeparator()).mkString
)

logger.debug("execve available")
Execve.execve(
command.head,
"bun" +: command.tail.toArray,
sys.env.toArray.sorted.map { case (k, v) => s"$k=$v" }
)
sys.error("should not happen")
}
else {
logger.log(
s"Running ${command.mkString(" ")}",
" Running" + System.lineSeparator() +
command.iterator.map(_ + System.lineSeparator()).mkString
)

new ProcessBuilder(command*)
.inheritIO()
.start()
}
}

def runNative(
launcher: File,
args: Seq[String],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ object DirectivesPreprocessingUtils {
directives.ScalaVersion.handler,
directives.Sources.handler,
directives.Watching.handler,
directives.Tests.handler
directives.Tests.handler,
directives.Wasm.handler
).map(_.mapE(_.buildOptions))

val usingDirectiveWithReqsHandlers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ object BuiltInRules extends CommandHelpers {
JavaHome.handler.keys,
ScalaNative.handler.keys,
ScalaJs.handler.keys,
Wasm.handler.keys,
ScalacOptions.handler.keys,
JavaOptions.handler.keys,
JavacOptions.handler.keys,
Expand Down
Loading
Loading