Skip to content

Support Scala.js #127

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
May 1, 2025
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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,23 @@ jobs:
run: sbt rootNative/test
- name: Stress Test with Lower Memory
run: env GC_MAXIMUM_HEAP_SIZE=140M sbt 'rootNative/testOnly StressTest'
test-js:
strategy:
fail-fast: false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- uses: coursier/cache-action@v6
- uses: actions/setup-java@v3
with:
distribution: temurin
java-version: 21
- uses: actions/setup-node@v4
with:
node-version: '>=23'
- uses: sbt/setup-sbt@v1
- name: Test
run: sbt rootJS/test
104 changes: 103 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import org.scalajs.jsenv.nodejs._
import org.scalajs.linker.interface.ESVersion
import sbtcrossproject.CrossPlugin.autoImport.{CrossType, crossProject}
import scalanative.build._

Expand All @@ -18,7 +20,7 @@ inThisBuild(
)

lazy val root =
crossProject(JVMPlatform, NativePlatform)
crossProject(JSPlatform, JVMPlatform, NativePlatform)
.crossType(CrossType.Full)
.in(file("."))
.settings(
Expand All @@ -41,3 +43,103 @@ lazy val root =
}
)
)
.jsSettings(
Seq(
scalaVersion := "3.7.1-RC1-bin-20250425-fb6cc9b-NIGHTLY",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think it should be fine to keep this as-is for now, until 3.7.0 drops.

// Emit ES modules with the Wasm backend
scalaJSLinkerConfig := {
scalaJSLinkerConfig.value
.withESFeatures(_.withESVersion(ESVersion.ES2017)) // enable async/await
.withExperimentalUseWebAssembly(true) // use the Wasm backend
.withModuleKind(ModuleKind.ESModule) // required by the Wasm backend
},
// Configure Node.js (at least v23) to support the required Wasm features
jsEnv := {
val config = NodeJSEnv
.Config()
.withArgs(
List(
"--experimental-wasm-exnref", // always required
"--experimental-wasm-jspi", // required for js.async/js.await
"--experimental-wasm-imported-strings", // optional (good for performance)
"--turboshaft-wasm" // optional, but significantly increases stability
)
)
new NodeJSEnv(config)
},

// Patch the sjsir of JSPI to introduce primitives by hand
Compile / compile := {
val analysis = (Compile / compile).value

val s = streams.value
val classDir = (Compile / classDirectory).value
val jspiIRFile = classDir / "gears/async/js/JSPI$.sjsir"
patchJSPIIR(jspiIRFile, s)

analysis
}
)
)

def patchJSPIIR(jspiIRFile: File, streams: TaskStreams): Unit = {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note that this is only to support js.async/await until the Scala 3 compiler natively supports it

import org.scalajs.ir.Names._
import org.scalajs.ir.Trees._
import org.scalajs.ir.Types._
import org.scalajs.ir.WellKnownNames._

val content = java.nio.ByteBuffer.wrap(java.nio.file.Files.readAllBytes(jspiIRFile.toPath()))
val classDef = org.scalajs.ir.Serializers.deserialize(content)

val newMethods = classDef.methods.mapConserve { m =>
(m.methodName.simpleName.nameString, m.body) match {
case ("async", Some(UnaryOp(UnaryOp.Throw, _))) =>
implicit val pos = m.pos
val closure = Closure(
ClosureFlags.arrow.withAsync(true),
m.args,
Nil,
None,
AnyType,
Apply(ApplyFlags.empty, m.args.head.ref, MethodIdent(MethodName("apply", Nil, ObjectRef)), Nil)(AnyType),
m.args.map(_.ref)
)
val newBody = Some(JSFunctionApply(closure, Nil))
m.copy(body = newBody)(m.optimizerHints, m.version)(m.pos)

case ("await", Some(UnaryOp(UnaryOp.Throw, _))) =>
implicit val pos = m.pos
val newBody = Some(JSAwait(m.args.head.ref))
m.copy(body = newBody)(m.optimizerHints, m.version)(m.pos)

case _ =>
m
}
}

if (newMethods ne classDef.methods) {
streams.log.info("Patching JSPI$.sjsir")
val newClassDef = {
import classDef._
ClassDef(
name,
originalName,
kind,
jsClassCaptures,
superClass,
interfaces,
jsSuperClass,
jsNativeLoadSpec,
fields,
newMethods,
jsConstructor,
jsMethodProps,
jsNativeMembers,
topLevelExportDefs
)(optimizerHints)(pos)
}
val baos = new java.io.ByteArrayOutputStream()
org.scalajs.ir.Serializers.serialize(baos, newClassDef)
java.nio.file.Files.write(jspiIRFile.toPath(), baos.toByteArray())
}
}
27 changes: 15 additions & 12 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

69 changes: 43 additions & 26 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,52 @@
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};

outputs = inputs@{ flake-parts, ... }:
outputs =
inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [ ];
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
perSystem = { config, self', inputs', pkgs, system, ... }: {
# Per-system attributes can be defined here. The self' and inputs'
# module parameters provide easy access to attributes of the same
# system.
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
jdk21
# Scala deps
(sbt.override { jre = jdk21; })
# Scala Native deps
llvm
clang
boehmgc
libunwind
zlib
# Dev deps
metals
scalafix
scalafmt
];
shellHook = ''
export LLVM_BIN=${pkgs.clang}/bin
'';
systems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
"x86_64-darwin"
];
perSystem =
{
config,
self',
inputs',
pkgs,
system,
...
}:
{
# Per-system attributes can be defined here. The self' and inputs'
# module parameters provide easy access to attributes of the same
# system.
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
jdk21
# Scala deps
(sbt.override { jre = jdk21; })
# Scala Native deps
llvm
clang
boehmgc
libunwind
zlib
# Scala.js deps
nodejs_23
# Dev deps
metals
scalafix
scalafmt
];
shellHook = ''
export LLVM_BIN=${pkgs.clang}/bin
'';
};
};
};
flake = {
# The usual flake attributes can be defined here, including system-
# agnostic ones like nixosModule and system-enumerating ones, although
Expand Down
3 changes: 3 additions & 0 deletions js/src/main/scala/async/AsyncImpl.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package gears.async

private[async] abstract class AsyncImpl
18 changes: 18 additions & 0 deletions js/src/main/scala/async/JSPI.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package gears.async.js

import scala.scalajs.js
import scala.scalajs.js.annotation.*

/** A stub implementation of the [[js.async]]/[[js.await]] functions, which are not yet available natively on Scala 3.
*
* See how the stubs are compiled in `build.sbt`.
*/
private[async] object JSPI:
@inline
def async[A](computation: => A): js.Promise[A] =
throw new Error("async stub")

@inline
def await[A](p: js.Promise[A]): A =
throw new Error("await stub")
end JSPI
40 changes: 40 additions & 0 deletions js/src/main/scala/async/ListenerImpl.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package gears.async

import gears.async.js.JSPI

import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.*
import scala.concurrent.duration.*
import scala.scalajs.js

/** Assumes top-level await, probably will crash real hard. */
private[async] trait NumberedLockImpl:
protected val numberedLock = new Lock:
var locked = false
val queue = scala.collection.mutable.Queue[(Unit) => Any]()

override def newCondition(): Condition =
throw NotImplementedError()

override def tryLock(): Boolean =
if locked then false
else
locked = true
true

override def tryLock(time: Long, unit: TimeUnit): Boolean =
throw NotImplementedError()

override def lock(): Unit =
while !tryLock() do
val promise = js.Promise[Unit]: (resolve, _) =>
queue += resolve
JSPI.await(promise)

override def unlock(): Unit =
assert(locked, "unlocking an unlocked lock")
locked = false
if !queue.isEmpty then queue.dequeue()(())

override def lockInterruptibly(): Unit =
throw NotImplementedError()
Loading