Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
155 changes: 149 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?

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,16 @@ 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 !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 +305,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 +342,114 @@ 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 extraEnv =
if (emitWasm && denoNeedsWasmFlag) Map("DENO_V8_FLAGS" -> "--experimental-wasm-exnref")
else Map.empty

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