Skip to content
Merged
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
34 changes: 34 additions & 0 deletions modules/build/src/test/scala/scala/build/tests/SourcesTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,40 @@ class SourcesTests extends TestUtil.ScalaCliBuildSuite {
}
}

test("wasmJspi using directive enables JSPI for Wasm output") {
val testInputs = TestInputs(
os.rel / "something.sc" ->
"""//> using wasm
|//> using wasmJspi
|//> using jsModuleKind es
|""".stripMargin
)
testInputs.withInputs { (root, inputs) =>
val (crossSources, _) =
CrossSources.forInputs(
inputs,
preprocessors,
TestLogger(),
SuppressWarningOptions()
).orThrow

val scopedSources = crossSources.scopedSources(BuildOptions()).orThrow
val sources =
scopedSources.sources(
Scope.Main,
crossSources.sharedOptions(BuildOptions()),
root,
TestLogger()
)
.orThrow

val jsOptions = sources.buildOptions.scalaJsOptions
val jsConfig = jsOptions.linkerConfig(TestLogger()).orThrow
expect(jsOptions.jsEmitWasm, jsOptions.jsWasmJspi)
expect(jsConfig.linkerCliArgs.contains("--useJSPI"))
}
}

test("js options in using directives failure - multiple values") {
val testInputs = TestInputs(
os.rel / "something.sc" ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ final case class ScalaJsOptions(
@HelpMessage("Enable Wasm output (Scala.js Wasm backend). Uses Node.js by default. To show more options for Wasm pass `--help-wasm`")
jsEmitWasm: Option[Boolean] = None,

@Group(HelpGroup.Wasm.toString)
@Tag(tags.experimental)
@HelpMessage("Use JavaScript Promise Integration (JSPI) in Wasm output, required by js.async/js.await (Scala.js 1.22+)")
jsWasmJspi: Option[Boolean] = None,

@Group(HelpGroup.ScalaJs.toString)
@Tag(tags.experimental)
@HelpMessage("JS runtime to run the output on: node (default), deno, bun")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ final case class SharedOptions(
remapEsModuleImportMap =
jsEsModuleImportMap.filter(_.trim.nonEmpty).map(os.Path(_, Os.pwd)),
jsEmitWasm = jsEmitWasm.getOrElse(false),
jsWasmJspi = jsWasmJspi.getOrElse(false),
jsRuntime = parsedRuntime
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,29 @@ import scala.cli.commands.SpecificationLevel

@DirectiveGroupName("Wasm options")
@DirectiveExamples("//> using wasm")
@DirectiveExamples("//> using wasmJspi")
@DirectiveExamples("//> using jsRuntime node")
@DirectiveExamples("//> using jsRuntime deno")
@DirectiveExamples("//> using jsRuntime bun")
@DirectiveUsage(
"//> using wasm|jsRuntime _value_",
"//> using wasm|wasmJspi|jsRuntime _value_",
"""
|`//> using wasm` _true|false_
|
|`//> using wasm`
|
|`//> using wasmJspi` _true|false_
|
|`//> using wasmJspi`
|
|`//> using jsRuntime` _node|deno|bun_
|""".stripMargin
)
@DirectiveDescription("Add WebAssembly options")
@DirectiveLevel(SpecificationLevel.EXPERIMENTAL)
final case class Wasm(
wasm: Option[Boolean] = None,
wasmJspi: Option[Boolean] = None,
jsRuntime: Option[String] = None
) extends HasBuildOptions {
def buildOptions: Either[BuildException, BuildOptions] = {
Expand All @@ -47,7 +53,11 @@ final case class Wasm(
ScalaOptions()
BuildOptions(
scalaOptions = scalaOptions,
scalaJsOptions = ScalaJsOptions(jsEmitWasm = wasmEnabled, jsRuntime = runtime)
scalaJsOptions = ScalaJsOptions(
jsEmitWasm = wasmEnabled,
jsWasmJspi = wasmJspi.getOrElse(false),
jsRuntime = runtime
)
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,39 @@ trait RunScalaJsTestDefinitions { this: RunTestDefinitions =>
}
}

// js.async/js.await support was only introduced in Scala 3.8+ (earlier, linking fails with
// "Referring to non-existent method scala.scalajs.js.package$.async").
if isScala38OrNewer then
test("Run Wasm with js.async/js.await via --js-wasm-jspi") {
val inputs = TestInputs(
os.rel / "Async.scala" ->
"""import scala.scalajs.js
|
|object Async {
| def main(args: Array[String]): Unit =
| js.async {
| val x = js.await(js.Promise.resolve[Int](21))
| println(s"JSPI ${x * 2}")
| }
|}
|""".stripMargin
)
inputs.fromRoot { root =>
val output = os.proc(
TestUtil.cli,
"--power",
"run",
"Async.scala",
"--js-emit-wasm",
"--js-module-kind",
"es",
"--js-wasm-jspi",
extraOptions
).call(cwd = root).out.trim()
expect(output == "JSPI 42")
}
}

test("Wasm without an ES module kind fails fast") {
val inputs = TestInputs(
os.rel / "Hello.scala" ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ final case class ScalaJsLinkerConfig(
prettyPrint: Boolean = false,
relativizeSourceMapBase: Option[String] = None,
remapEsModuleImportMap: Option[os.Path] = None,
emitWasm: Boolean = false
emitWasm: Boolean = false,
wasmUseJspi: Boolean = false
) {
def linkerCliArgs: Seq[String] = {
val moduleKindArgs = Seq("--moduleKind", moduleKind)
Expand All @@ -36,6 +37,7 @@ final case class ScalaJsLinkerConfig(
Seq("--importmap", remapEsModuleImportMap.getOrElse(os.pwd / "importmap.json").toString)
else Nil
val jsEmitWasm = if (emitWasm) Seq("--emitWasm") else Nil
val jsWasmJspi = if (emitWasm && wasmUseJspi) Seq("--useJSPI") else Nil

val configArgs = Seq[os.Shellable](
moduleKindArgs,
Expand All @@ -48,7 +50,8 @@ final case class ScalaJsLinkerConfig(
jsHeaderArg,
prettyPrintArgs,
jsEsModuleImportMap,
jsEmitWasm
jsEmitWasm,
jsWasmJspi
)

configArgs.flatMap(_.value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ final case class ScalaJsOptions(
esVersionStr: Option[String] = None,
noOpt: Option[Boolean] = None,
jsEmitWasm: Boolean = false,
jsWasmJspi: Boolean = false,
jsRuntime: JSRuntime = JSRuntime.default
) {
def fullOpt: Either[UnrecognizedJsOptModeError, Boolean] =
Expand Down Expand Up @@ -136,6 +137,10 @@ final case class ScalaJsOptions(

def linkerConfig(logger: Logger): Either[BuildException, ScalaJsLinkerConfig] =
esVersion.map { resolvedEsVersion =>
if jsWasmJspi && !jsEmitWasm then
logger.message(
"Warning: --js-wasm-jspi only applies to Wasm output (--js-emit-wasm), ignoring it"
)
val esFeatureDefaults = ScalaJsLinkerConfig.ESFeatures()
val esFeatures = ScalaJsLinkerConfig.ESFeatures(
allowBigIntsForLongs =
Expand All @@ -154,7 +159,8 @@ final case class ScalaJsOptions(
esFeatures = esFeatures,
jsHeader = header,
remapEsModuleImportMap = remapEsModuleImportMap,
emitWasm = jsEmitWasm
emitWasm = jsEmitWasm,
wasmUseJspi = jsWasmJspi
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,24 @@ class ScalaJsOptionsTests extends munit.FunSuite {
test("the es version is passed to the linker") {
expect(linkerArgsFor("es2022").containsSlice(Seq("--esVersion", "ES2022")))
}

private def linkerArgs(options: ScalaJsOptions): Seq[String] =
options.linkerConfig(Logger.nop) match {
case Right(config) => config.linkerCliArgs
case Left(e) => sys.error(s"Expected a linker config, got $e")
}

test("JSPI is passed to the linker with Wasm output") {
val args = linkerArgs(ScalaJsOptions(jsEmitWasm = true, jsWasmJspi = true))
expect(args.contains("--emitWasm"))
expect(args.contains("--useJSPI"))
}

test("JSPI is off by default") {
expect(!linkerArgs(ScalaJsOptions(jsEmitWasm = true)).contains("--useJSPI"))
}

test("JSPI is not passed to the linker without Wasm output") {
expect(!linkerArgs(ScalaJsOptions(jsWasmJspi = true)).contains("--useJSPI"))
}
}
16 changes: 15 additions & 1 deletion website/docs/guides/advanced/scala-wasm.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ enough to require it, pass it yourself via the runtime's own environment variabl

The same mechanism gives you any **optional** feature flags — Scala CLI never injects these either:

- `--experimental-wasm-jspi` — required to use `js.async` / `js.await`.
- `--experimental-wasm-jspi` — required to use `js.async` / `js.await` on runtimes that do not
enable JSPI by default (Node.js 25+ does).
- `--experimental-wasm-imported-strings` — improves performance.
- `--turboshaft-wasm` — improves stability on Node.js 23.x (Node.js 24+ enables it by default and
has removed the flag).
Expand All @@ -79,6 +80,19 @@ The same mechanism gives you any **optional** feature flags — Scala CLI never
NODE_OPTIONS=--experimental-wasm-jspi scala-cli run Hello.scala --js-emit-wasm --js-module-kind es
```

## `js.async` / `js.await` (JSPI)

On the Wasm backend, `js.async` / `js.await` use JavaScript Promise Integration (JSPI). Since
Scala.js 1.22 the linker only links them when JSPI is enabled explicitly. Pass `--js-wasm-jspi`
(or use the `//> using wasmJspi` directive) together with `--js-emit-wasm`:

```bash ignore
scala-cli run Async.scala --js-emit-wasm --js-module-kind es --js-wasm-jspi
```

Without it, linking fails with `Uses an async block without JSPI support in WebAssembly`.
The runtime must support JSPI too (see above).

## Limitations

These come from the Scala.js Wasm backend itself, not from Scala CLI:
Expand Down
4 changes: 4 additions & 0 deletions website/docs/reference/cli-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,10 @@ Enable jsdom

Enable Wasm output (Scala.js Wasm backend). Uses Node.js by default. To show more options for Wasm pass `--help-wasm`

### `--js-wasm-jspi`

Use JavaScript Promise Integration (JSPI) in Wasm output, required by js.async/js.await (Scala.js 1.22+)

### `--js-runtime`

JS runtime to run the output on: node (default), deno, bun
Expand Down
6 changes: 6 additions & 0 deletions website/docs/reference/directives.md
Original file line number Diff line number Diff line change
Expand Up @@ -751,12 +751,18 @@ Add WebAssembly options

`//> using wasm`

`//> using wasmJspi` _true|false_

`//> using wasmJspi`

`//> using jsRuntime` _node|deno|bun_


#### Examples
`//> using wasm`

`//> using wasmJspi`

`//> using jsRuntime node`

`//> using jsRuntime deno`
Expand Down
Loading