From a2bdab9eb729de51ae27edd3adbe6dd15443401b Mon Sep 17 00:00:00 2001 From: Flavio Brasil Date: Fri, 5 Jun 2026 09:38:44 -0300 Subject: [PATCH 1/3] [build] wasm support ### Problem Kyo cross-compiles to JVM, Scala.js, and Scala Native, but not WebAssembly. Scala.js now ships an experimental WebAssembly backend (WasmGC), so the toolchain exists; Kyo simply had no target wired for it. The goal is WASM as a first-class, tested cross-build target, so the full stack (up to and including kyo-http servers) runs on WasmGC runtimes. ### Solution Add a `WasmPlatform` to the sbt cross-build, mirroring the existing Scala.js setup (it enables the Scala.js plugin with the experimental WebAssembly linker and ESModule output). The WASM backend shares Scala.js's single-threaded, Node-hosted model, so JS and WASM share platform code through a `js-wasm` source directory. Most of the diff is mechanical: JS-only sources moving into that shared directory so both backends pick them up. Every module that targets Scala.js now also targets WASM, except the two cats-effect bridges (see Notes), with a matching CI matrix row and aggregator. The platform-specific code is small: a per-platform trampoline depth in `kyo.internal.Platform`, a few Node builtin facades for the http tier, and a `notWasm` tag for the two kernel tests that deliberately overflow the stack. The README gains a Platforms section and a WASM column across the module tables. ### Notes - Requires **Node 24+**. Node 24 makes V8's Turboshaft Wasm pipeline the default; Node 22/23 miscompile the generated WasmGC code under legacy TurboFan, and Node 24 removed the opt-in flag. CI, CONTRIBUTING, and the build pin and document this. - `kyo-cats` and `kyo-compat-ce` are intentionally not built for WASM: cats-effect does not support the backend yet, filed upstream as [cats-effect#4608](https://github.com/typelevel/cats-effect/issues/4608). They re-enable once that lands. - This also lowers Scala Native's trampoline depth to match WASM (256, vs 512 on JVM/JS), since native frames are larger and the lower bound is the safe shared value. Flagging it because it changes existing Native behavior. - Validated the WASM suite on Node 24 module by module; kyo-http serves real HTTP and HTTPS over Node sockets under WASM. The Chrome/Docker-driven modules (kyo-browser, kyo-ui, kyo-pod) run through the CI matrix. - Folds in one pre-existing README fix: `kyo-reactive-streams` was marked JVM-only but already cross-compiles to JS and Native (corrected, plus WASM). --- .github/workflows/build-main.yml | 2 +- .github/workflows/build-pr.yml | 2 +- .github/workflows/build.yml | 13 +- CONTRIBUTING.md | 2 +- README.md | 137 +++++++++------- build.sbt | 151 +++++++++++++++--- .../internal/BrowserLauncherPlatform.scala | 0 .../BrowserLauncherPlatformTest.scala | 0 .../kyo/compat/internal/CompatScheduler.scala | 0 .../src/main/scala/kyo/AsyncStubs.scala | 0 .../src/main/scala/kyo/VarHandle.scala | 0 .../src/main/scala/kyo/addersStubs.scala | 0 .../src/main/scala/kyo/hubsStubs.scala | 0 .../kyo/internal/AsyncPlatformSpecific.scala | 0 .../kyo/internal/KyoAppPlatformSpecific.scala | 0 .../kyo/internal/KyoAppRunnerPlatform.scala | 0 .../internal/OSSignalPlatformSpecific.scala | 0 .../kyo/internal/PathPlatformSpecific.scala | 8 +- .../internal/ProcessPlatformSpecific.scala | 17 +- .../kyo/internal/SystemPlatformSpecific.scala | 0 .../scheduler/IOPromisePlatformSpecific.scala | 0 .../internal/JSServiceLoaderRegistry.scala | 0 .../kyo/stats/internal/ServiceLoader.scala | 0 .../src/main/scala/kyo/timersSubs.scala | 0 .../internal/MpmcUnboundedUnsafeQueue.scala | 0 .../scala/kyo/internal/MpmcUnsafeQueue.scala | 0 .../internal/MpscUnboundedUnsafeQueue.scala | 0 .../scala/kyo/internal/MpscUnsafeQueue.scala | 0 .../internal/SpmcUnboundedUnsafeQueue.scala | 0 .../scala/kyo/internal/SpmcUnsafeQueue.scala | 0 .../internal/SpscUnboundedUnsafeQueue.scala | 0 .../scala/kyo/internal/SpscUnsafeQueue.scala | 0 .../internal/UnboundedUnsafeQueueBase.scala | 0 .../main/scala/kyo/internal/Platform.scala | 6 + .../main/scala/kyo/internal/Platform.scala | 6 + .../main/scala/kyo/internal/Platform.scala | 9 +- .../main/scala/kyo/internal/Platform.scala | 37 +++++ .../kyo/doctest/sbt/KyoDoctestPlugin.scala | 10 +- .../scala/java/security/SecureRandom.scala | 0 .../kyo/internal/HttpPlatformTransport.scala | 0 .../main/scala/kyo/internal/JsHandle.scala | 0 .../main/scala/kyo/internal/JsIoDriver.scala | 0 .../main/scala/kyo/internal/JsTransport.scala | 16 +- .../scala/kyo/internal/NodeBuiltins.scala | 46 ++++++ .../internal/HttpTestPlatformBackend.scala | 0 .../scala/kyo/internal/TlsTestHelper.scala | 11 +- .../internal/UnixSocketTestHelperImpl.scala | 10 +- kyo-kernel/README.md | 2 +- .../scala/kyo/kernel/internal/TracePool.scala | 0 .../scala/kyo/kernel/internal/package.scala | 4 +- .../shared/src/test/scala/kyo/KyoTest.scala | 4 +- .../src/test/scala/kyo/kernel/Tagged.scala | 1 + .../src/test/scala/kyo/WasmParseTest.scala | 5 + .../src/test/scala/kyo/ContainerRuntime.scala | 8 +- .../src/test/scala/kyo/PodNodeBuiltins.scala | 24 +++ .../src/test/scala/kyo/ChoiceTest.scala | 2 +- .../shared/src/test/scala/kyo/Tagged.scala | 1 + .../scala/kyo/scheduler/Coordinator.scala | 0 .../scala/kyo/scheduler/InternalClock.scala | 0 .../main/scala/kyo/scheduler/Scheduler.scala | 0 .../scala/kyo/scheduler/util/Threads.scala | 0 .../kyo/internal/AsciiStringFactory.scala | 0 .../kyo/stats/otlp/OTLPRegistration.scala | 0 .../scala/java/lang/ref/WeakReference.scala | 0 .../src/main/scala/kyo/UILocation.scala | 0 .../src/main/scala/kyo/UIMount.scala | 0 .../src/main/scala/kyo/UIWindow.scala | 0 .../main/scala/kyo/internal/DomBackend.scala | 0 .../scala/kyo/internal/DomStyleSheet.scala | 0 .../java/util/concurrent/CountDownLatch.scala | 2 +- project/TestKyo.scala | 21 ++- project/WasmPlatform.scala | 45 ++++++ 72 files changed, 459 insertions(+), 143 deletions(-) rename kyo-browser/{js => js-wasm}/src/main/scala/kyo/internal/BrowserLauncherPlatform.scala (100%) rename kyo-browser/{js => js-wasm}/src/test/scala/kyo/internal/BrowserLauncherPlatformTest.scala (100%) rename kyo-compat/bindings/future/{js => js-wasm}/src/main/scala/kyo/compat/internal/CompatScheduler.scala (100%) rename kyo-core/{js => js-wasm}/src/main/scala/kyo/AsyncStubs.scala (100%) rename kyo-core/{js => js-wasm}/src/main/scala/kyo/VarHandle.scala (100%) rename kyo-core/{js => js-wasm}/src/main/scala/kyo/addersStubs.scala (100%) rename kyo-core/{js => js-wasm}/src/main/scala/kyo/hubsStubs.scala (100%) rename kyo-core/{js => js-wasm}/src/main/scala/kyo/internal/AsyncPlatformSpecific.scala (100%) rename kyo-core/{js => js-wasm}/src/main/scala/kyo/internal/KyoAppPlatformSpecific.scala (100%) rename kyo-core/{js => js-wasm}/src/main/scala/kyo/internal/KyoAppRunnerPlatform.scala (100%) rename kyo-core/{js => js-wasm}/src/main/scala/kyo/internal/OSSignalPlatformSpecific.scala (100%) rename kyo-core/{js => js-wasm}/src/main/scala/kyo/internal/PathPlatformSpecific.scala (99%) rename kyo-core/{js => js-wasm}/src/main/scala/kyo/internal/ProcessPlatformSpecific.scala (98%) rename kyo-core/{js => js-wasm}/src/main/scala/kyo/internal/SystemPlatformSpecific.scala (100%) rename kyo-core/{js => js-wasm}/src/main/scala/kyo/scheduler/IOPromisePlatformSpecific.scala (100%) rename kyo-core/{js => js-wasm}/src/main/scala/kyo/stats/internal/JSServiceLoaderRegistry.scala (100%) rename kyo-core/{js => js-wasm}/src/main/scala/kyo/stats/internal/ServiceLoader.scala (100%) rename kyo-core/{js => js-wasm}/src/main/scala/kyo/timersSubs.scala (100%) rename kyo-data/{js => js-wasm}/src/main/scala/kyo/internal/MpmcUnboundedUnsafeQueue.scala (100%) rename kyo-data/{js => js-wasm}/src/main/scala/kyo/internal/MpmcUnsafeQueue.scala (100%) rename kyo-data/{js => js-wasm}/src/main/scala/kyo/internal/MpscUnboundedUnsafeQueue.scala (100%) rename kyo-data/{js => js-wasm}/src/main/scala/kyo/internal/MpscUnsafeQueue.scala (100%) rename kyo-data/{js => js-wasm}/src/main/scala/kyo/internal/SpmcUnboundedUnsafeQueue.scala (100%) rename kyo-data/{js => js-wasm}/src/main/scala/kyo/internal/SpmcUnsafeQueue.scala (100%) rename kyo-data/{js => js-wasm}/src/main/scala/kyo/internal/SpscUnboundedUnsafeQueue.scala (100%) rename kyo-data/{js => js-wasm}/src/main/scala/kyo/internal/SpscUnsafeQueue.scala (100%) rename kyo-data/{js => js-wasm}/src/main/scala/kyo/internal/UnboundedUnsafeQueueBase.scala (100%) create mode 100644 kyo-data/wasm/src/main/scala/kyo/internal/Platform.scala rename kyo-http/{js => js-wasm}/src/main/scala/java/security/SecureRandom.scala (100%) rename kyo-http/{js => js-wasm}/src/main/scala/kyo/internal/HttpPlatformTransport.scala (100%) rename kyo-http/{js => js-wasm}/src/main/scala/kyo/internal/JsHandle.scala (100%) rename kyo-http/{js => js-wasm}/src/main/scala/kyo/internal/JsIoDriver.scala (100%) rename kyo-http/{js => js-wasm}/src/main/scala/kyo/internal/JsTransport.scala (95%) create mode 100644 kyo-http/js-wasm/src/main/scala/kyo/internal/NodeBuiltins.scala rename kyo-http/{js => js-wasm}/src/test/scala/kyo/internal/HttpTestPlatformBackend.scala (100%) rename kyo-http/{js => js-wasm}/src/test/scala/kyo/internal/TlsTestHelper.scala (66%) rename kyo-http/{js => js-wasm}/src/test/scala/kyo/internal/UnixSocketTestHelperImpl.scala (55%) rename kyo-kernel/{js => js-wasm}/src/main/scala/kyo/kernel/internal/TracePool.scala (100%) create mode 100644 kyo-parse/wasm/src/test/scala/kyo/WasmParseTest.scala rename kyo-pod/{js => js-wasm}/src/test/scala/kyo/ContainerRuntime.scala (80%) create mode 100644 kyo-pod/js-wasm/src/test/scala/kyo/PodNodeBuiltins.scala rename kyo-scheduler/{js => js-wasm}/src/main/scala/kyo/scheduler/Coordinator.scala (100%) rename kyo-scheduler/{js => js-wasm}/src/main/scala/kyo/scheduler/InternalClock.scala (100%) rename kyo-scheduler/{js => js-wasm}/src/main/scala/kyo/scheduler/Scheduler.scala (100%) rename kyo-scheduler/{js => js-wasm}/src/main/scala/kyo/scheduler/util/Threads.scala (100%) rename kyo-schema/{js => js-wasm}/src/main/scala/kyo/internal/AsciiStringFactory.scala (100%) rename kyo-stats-otlp/{js => js-wasm}/src/main/scala/kyo/stats/otlp/OTLPRegistration.scala (100%) rename kyo-stats-registry/{js-native => js-native-wasm}/src/main/scala/java/lang/ref/WeakReference.scala (100%) rename kyo-ui/{js => js-wasm}/src/main/scala/kyo/UILocation.scala (100%) rename kyo-ui/{js => js-wasm}/src/main/scala/kyo/UIMount.scala (100%) rename kyo-ui/{js => js-wasm}/src/main/scala/kyo/UIWindow.scala (100%) rename kyo-ui/{js => js-wasm}/src/main/scala/kyo/internal/DomBackend.scala (100%) rename kyo-ui/{js => js-wasm}/src/main/scala/kyo/internal/DomStyleSheet.scala (100%) rename kyo-zio/{js => js-wasm}/src/test/scala/java/util/concurrent/CountDownLatch.scala (67%) create mode 100644 project/WasmPlatform.scala diff --git a/.github/workflows/build-main.yml b/.github/workflows/build-main.yml index 74f39d2cc9..1a83edf769 100644 --- a/.github/workflows/build-main.yml +++ b/.github/workflows/build-main.yml @@ -4,7 +4,7 @@ on: branches: - main -# One job per arch. They run in parallel; each internally serializes JVM → JS → Native +# One job per arch. They run in parallel; each internally serializes JVM, JS, Native, Wasm # via build.yml's `max-parallel: 1` matrix. Add or comment out arches here when budget # allows; the runner-image mapping is duplicated below to keep the workflow # self-contained. diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 30e36e2f3f..efecb3c8d5 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -9,7 +9,7 @@ concurrency: cancel-in-progress: true # One job per arch. They run in parallel (no `needs:` between them) but each one -# internally serializes JVM → JS → Native via build.yml's `max-parallel: 1` matrix. +# internally serializes JVM, JS, Native, Wasm via build.yml's `max-parallel: 1` matrix. # Add or comment out arches here when budget allows; the runner-image mapping for each # is duplicated below to keep the workflow self-contained. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1205591d1c..20e7f336d2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,8 +1,8 @@ name: build # Reusable workflow: builds + tests one arch with the target axis serialized. # -# Sequential within arch: `max-parallel: 1` on the `target` matrix means JVM → JS → -# Native run one at a time inside this workflow invocation. Two same-arch jobs never +# Sequential within arch: `max-parallel: 1` on the `target` matrix means JVM, JS, +# Native, Wasm run one at a time inside this workflow invocation. Two same-arch jobs never # compete for the same runner pool, which previously caused container-stop timeouts # and resource starvation on arm64 under contention. # @@ -32,7 +32,7 @@ jobs: fail-fast: false max-parallel: 1 matrix: - target: [JVM, JS, Native] + target: [JVM, JS, Native, Wasm] runs-on: ${{ inputs.runner-image }} env: JAVA_OPTS: >- @@ -49,10 +49,13 @@ jobs: with: fetch-depth: 0 + # The WASM target requires Node 24+: it defaults to V8's Turboshaft Wasm pipeline (Node 22/23 + # miscompile the generated WasmGC code and no longer accept --turboshaft-wasm). JS runs on the + # same version to keep a single Node across the matrix. - uses: actions/setup-node@v6.4.0 - if: matrix.target == 'JS' + if: matrix.target == 'JS' || matrix.target == 'Wasm' with: - node-version: '22' + node-version: '24' - uses: actions/cache@v5 env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44f865a310..adff301ba2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,7 @@ Before you begin, make sure you have the following installed: - **Java 21 (or later)** - **Scala** -- **Node** +- **Node** (Node 24+ to run the experimental WASM target: it defaults to V8's Turboshaft Wasm pipeline) - **sbt** (Scala Build Tool) - **Git** diff --git a/README.md b/README.md index 6baed44631..ac4716301a 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Discord](https://img.shields.io/discord/1087005439859904574?logo=discord&logoColor=white&label=discord&color=5865F2)](https://discord.gg/KxxkBbW8bq) [![License](https://img.shields.io/github/license/getkyo/kyo?color=blue)](LICENSE.txt) -Kyo is a Scala 3 toolkit for building applications. One source tree compiles to JVM, JavaScript, and Scala Native. The library is built on algebraic effects with modular handlers, exposed through a compact infix type `A < S` ("A pending S"), where `S` is an open, type-level *set* of effects rather than a fixed `R, E, A` triple or a single concrete `IO`. An effect such as `Var[State]`, `Emit[Log]`, or `Abort[NotFound]` becomes another member of the set at the call site. Capabilities are not threaded through an `R` environment, hidden inside a typeclass dictionary, or stacked through monad transformers. +Kyo is a Scala 3 toolkit for building applications. One source tree compiles to JVM, JavaScript, Scala Native, and WebAssembly. The library is built on algebraic effects with modular handlers, exposed through a compact infix type `A < S` ("A pending S"), where `S` is an open, type-level *set* of effects rather than a fixed `R, E, A` triple or a single concrete `IO`. An effect such as `Var[State]`, `Emit[Log]`, or `Abort[NotFound]` becomes another member of the set at the call site. Capabilities are not threaded through an `R` environment, hidden inside a typeclass dictionary, or stacked through monad transformers. Kyo is pure monadic computation, programs-as-values. A value of type `A < S` is a pure description of a program (an immutable value that handlers interpret, not a deferred thunk you just run), composed with `map`, `flatMap`, and Scala 3's for-comprehensions the same way you compose `Option`, `Future`, `IO`, or `ZIO`. Algebraic effects do not replace the monadic style; they sit on top of it, letting the *effect row* be open and compositional while every program remains a pure value. @@ -220,6 +220,23 @@ Three migration paths cover most adopters: ZIO migrants looking for fluent extension methods (`.race`, `.timeout`, `.retry`, `.provide`, etc.) over Kyo effects should also see [`kyo-combinators`](kyo-combinators/README.md), which is also where Cats Effect migrants find the cats-syntax-style operators (`*>`, `<*`, `>>`) and the `forAbort[E1]` failure-narrowing DSL. Migrants whose fs2 / ZStream code crosses into Kyo can route through the bidirectional `Stream` bridge in [`kyo-reactive-streams`](kyo-reactive-streams/README.md). +## Platforms + +One source tree, one Scala 3 LTS compiler, four published targets: + +| Platform | Runtime | Coordinate | +| ------------ | -------------------- | ---------- | +| JVM | JDK 21+ | `%%` | +| Scala.js | Node.js, browsers | `%%%` | +| Scala Native | Native binary (LLVM) | `%%` | +| WebAssembly | Node.js 24+ | `%%%` | + +Scala.js and the WebAssembly backend share a single-threaded, event-loop concurrency model; on the JVM, Kyo runs its multi-threaded work-stealing scheduler. + +WebAssembly uses the experimental Scala.js WebAssembly backend (WasmGC). It runs on Node.js 24+, where V8's Turboshaft Wasm pipeline is the default; Kyo passes `--experimental-wasm-exnref` for the exception-handling opcodes the backend emits. Because the backend shares Scala.js's source and model, WASM coverage matches Scala.js with one exception: the cats-effect bridges `kyo-cats` and `kyo-compat-ce` are not built for WASM, because cats-effect does not yet support the backend ([cats-effect#4608](https://github.com/typelevel/cats-effect/issues/4608)). + +The Foundation, Application runtime, and most domain modules cross-compile to all four. Modules that wrap a JVM-only library are JVM only (`kyo-caliban`, `kyo-aeron`, the logging bridges, the scheduler embeds, `kyo-compat-ox` / `-twitter-future`); `kyo-offheap` and `kyo-scheduler-zio` are JVM and Native. The per-module tables below carry the exact matrix. + ## Modules Every module ships its own README. Open the linked README for the full surface, capabilities, callouts, and worked examples. The tables below name each module's identity in one sentence so you can pick the right one fast. Each identity cell names types and operations defined inside that module; expect unfamiliar names on first scan and treat the linked README as the source for what each one does. Platform columns mean published artifacts: ✅ = supported, ❌ = not built for that platform. @@ -228,110 +245,110 @@ Every module ships its own README. Open the linked README for the full surface, The substrate the rest of the ecosystem builds on. Most application code never depends on these directly; they ride in transitively through `kyo-core`. -| Module | JVM | JS | Native | Identity | -| -------------------------------------------- | --- | --- | ------ | --------------------------------------------------------------------------------------------------------- | -| [kyo-data](kyo-data/README.md) | ✅ | ✅ | ✅ | Low-allocation values: `Maybe`, `Result`, `Chunk`, `Span`, `Duration`, `Instant`, `Schedule`, `TypeMap` | -| [kyo-kernel](kyo-kernel/README.md) | ✅ | ✅ | ✅ | Algebraic-effects substrate; defines `A < S`, `ArrowEffect`, `ContextEffect`, multi-shot continuations | -| [kyo-prelude](kyo-prelude/README.md) | ✅ | ✅ | ✅ | Strictly-pure effect layer: `Abort`, `Env`, `Var`, `Memo`, `Choice`, `Emit`, `Poll`, `Stream`, `Layer` | +| Module | JVM | JS | Native | WASM | Identity | +| -------------------------------------------- | --- | --- | ------ | ---- | --------------------------------------------------------------------------------------------------------- | +| [kyo-data](kyo-data/README.md) | ✅ | ✅ | ✅ | ✅ | Low-allocation values: `Maybe`, `Result`, `Chunk`, `Span`, `Duration`, `Instant`, `Schedule`, `TypeMap` | +| [kyo-kernel](kyo-kernel/README.md) | ✅ | ✅ | ✅ | ✅ | Algebraic-effects substrate; defines `A < S`, `ArrowEffect`, `ContextEffect`, multi-shot continuations | +| [kyo-prelude](kyo-prelude/README.md) | ✅ | ✅ | ✅ | ✅ | Strictly-pure effect layer: `Abort`, `Env`, `Var`, `Memo`, `Choice`, `Emit`, `Poll`, `Stream`, `Layer` | ### Application runtime The runtime layer most apps depend on directly. `kyo-core` is the standard-library equivalent for Kyo applications; `kyo-scheduler` is the work-stealing fiber pool under it. -| Module | JVM | JS | Native | Identity | -| -------------------------------------------- | --- | --- | ------ | --------------------------------------------------------------------------------------------------------- | -| [kyo-scheduler](kyo-scheduler/README.md) | ✅ | ✅ | ✅ | Adaptive work-stealing pool with automatic blocking detection and admission control | -| [kyo-core](kyo-core/README.md) | ✅ | ✅ | ✅ | I/O and concurrency: `Sync`, `Async`, `Scope`, `Fiber`, `Channel`, `Hub`, `Queue`, `Clock`, `Log`, `Path` | +| Module | JVM | JS | Native | WASM | Identity | +| -------------------------------------------- | --- | --- | ------ | ---- | --------------------------------------------------------------------------------------------------------- | +| [kyo-scheduler](kyo-scheduler/README.md) | ✅ | ✅ | ✅ | ✅ | Adaptive work-stealing pool with automatic blocking detection and admission control | +| [kyo-core](kyo-core/README.md) | ✅ | ✅ | ✅ | ✅ | I/O and concurrency: `Sync`, `Async`, `Scope`, `Fiber`, `Channel`, `Hub`, `Queue`, `Clock`, `Log`, `Path` | ### HTTP and schema Web stack: HTTP client/server, derived JSON/Protobuf codecs, runtime config + feature flags, and a GraphQL surface. -| Module | JVM | JS | Native | Identity | -| -------------------------------------------- | --- | --- | ------ | --------------------------------------------------------------------------------------------------------- | -| [kyo-http](kyo-http/README.md) | ✅ | ✅ | ✅ | HTTP/1.1 client and server (no HTTP/2 or WebSockets) with shared API across JVM/JS/Native, bidirectional OpenAPI | -| [kyo-schema](kyo-schema/README.md) | ✅ | ✅ | ✅ | One `derives Schema` powers JSON, Protobuf, validation, lenses, diffs, builders, and structural conversion | -| [kyo-config](kyo-config/README.md) | ✅ | ✅ | ✅ | Type-safe config + feature flags with a percentage-rollout DSL, optional kyo-http admin and live sync | -| [kyo-caliban](kyo-caliban/README.md) | ✅ | ❌ | ❌ | Caliban GraphQL mounted on kyo-http: typed Kyo effects in resolvers, WebSocket subscriptions | +| Module | JVM | JS | Native | WASM | Identity | +| -------------------------------------------- | --- | --- | ------ | ---- | --------------------------------------------------------------------------------------------------------- | +| [kyo-http](kyo-http/README.md) | ✅ | ✅ | ✅ | ✅ | HTTP/1.1 client and server (no HTTP/2 or WebSockets) with shared API across JVM/JS/Native/WASM, bidirectional OpenAPI | +| [kyo-schema](kyo-schema/README.md) | ✅ | ✅ | ✅ | ✅ | One `derives Schema` powers JSON, Protobuf, validation, lenses, diffs, builders, and structural conversion | +| [kyo-config](kyo-config/README.md) | ✅ | ✅ | ✅ | ✅ | Type-safe config + feature flags with a percentage-rollout DSL, optional kyo-http admin and live sync | +| [kyo-caliban](kyo-caliban/README.md) | ✅ | ❌ | ❌ | ❌ | Caliban GraphQL mounted on kyo-http: typed Kyo effects in resolvers, WebSocket subscriptions | ### Direct style and combinators Two ways to write Kyo code more fluently. Pick `kyo-direct` for straight-line code with `.now` suspension points; pick `kyo-combinators` for ZIO-style fluent operators and the `forAbort[E1]` failure-narrowing DSL. -| Module | JVM | JS | Native | Identity | -| ---------------------------------------------- | --- | --- | ------ | --------------------------------------------------------------------------------------------------------- | -| [kyo-direct](kyo-direct/README.md) | ✅ | ✅ | ✅ | Direct-style: `direct { val x = effect.now; ... }` desugars to the equivalent `flatMap` chain | -| [kyo-combinators](kyo-combinators/README.md) | ✅ | ✅ | ✅ | Sanctioned home for symbolic operators (`*>`, `<*>`, `&>`) and the `forAbort[E1]` narrowing DSL | +| Module | JVM | JS | Native | WASM | Identity | +| ---------------------------------------------- | --- | --- | ------ | ---- | --------------------------------------------------------------------------------------------------------- | +| [kyo-direct](kyo-direct/README.md) | ✅ | ✅ | ✅ | ✅ | Direct-style: `direct { val x = effect.now; ... }` desugars to the equivalent `flatMap` chain | +| [kyo-combinators](kyo-combinators/README.md) | ✅ | ✅ | ✅ | ✅ | Sanctioned home for symbolic operators (`*>`, `<*>`, `&>`) and the `forAbort[E1]` narrowing DSL | ### Concurrent primitives Higher-level concurrency built on `kyo-core`'s fiber runtime. Reach for `kyo-actor` for typed message passing; `kyo-stm` for multi-cell atomicity; `kyo-offheap` for typed arrays outside the JVM heap. -| Module | JVM | JS | Native | Identity | -| ---------------------------------------------- | --- | --- | ------ | --------------------------------------------------------------------------------------------------------- | -| [kyo-actor](kyo-actor/README.md) | ✅ | ✅ | ✅ | Typed actors over `Channel` and `Fiber`: `Subject[A]`, `ask`, supervision by composition | -| [kyo-stm](kyo-stm/README.md) | ✅ | ✅ | ✅ | STM with `TRef` / `TMap` / `TChunk` / `TTable`, including compile-checked `TTable.Indexed` queries | -| [kyo-offheap](kyo-offheap/README.md) | ✅ | ❌ | ✅ | Arena-scoped typed primitive arrays via JEP 442 (JVM 22+) and `calloc`/`free` (Native) | +| Module | JVM | JS | Native | WASM | Identity | +| ---------------------------------------------- | --- | --- | ------ | ---- | --------------------------------------------------------------------------------------------------------- | +| [kyo-actor](kyo-actor/README.md) | ✅ | ✅ | ✅ | ✅ | Typed actors over `Channel` and `Fiber`: `Subject[A]`, `ask`, supervision by composition | +| [kyo-stm](kyo-stm/README.md) | ✅ | ✅ | ✅ | ✅ | STM with `TRef` / `TMap` / `TChunk` / `TTable`, including compile-checked `TTable.Indexed` queries | +| [kyo-offheap](kyo-offheap/README.md) | ✅ | ❌ | ✅ | ❌ | Arena-scoped typed primitive arrays via JEP 442 (JVM 22+) and `calloc`/`free` (Native) | ### Domain modules Domain-shaped modules: parsing, durable workflows, container management, low-latency messaging, browser automation, and web UIs. -| Module | JVM | JS | Native | Identity | -| ------------------------------------------------------ | --- | --- | ------ | --------------------------------------------------------------------------------------------------------- | -| [kyo-parse](kyo-parse/README.md) | ✅ | ✅ | ✅ | Parser combinators in the effect row; supports dual-input-type parsers (e.g. `Parse[Char] & Parse[Int]`) | -| [kyo-flow](kyo-flow/README.md) | ✅ | ✅ | ✅ | Durable workflow engine (Temporal/Cadence/ZIO-Flow space); value-replay execution, auto-generated REST | -| [kyo-pod](kyo-pod/README.md) | ✅ | ✅ | ✅ | Docker and Podman client cross-compiled to JVM/JS/Native, streaming logs/stats, scope-managed cleanup | -| [kyo-aeron](kyo-aeron/README.md) | ✅ | ❌ | ❌ | Typed pub/sub on Aeron: shared-memory IPC, UDP unicast, UDP multicast through one `Topic` API | -| [kyo-browser](kyo-browser/README.md) | ✅ | ✅ | ✅ | Browser automation over Chrome DevTools Protocol; settlement-aware actions, `readableContent` as Markdown | -| [kyo-ui](kyo-ui/README.md) | ✅ | ✅ | ✅ | Web UIs as pure values: one `UI` runs as a Scala.js DOM app (`runMount`), server HTML-over-SSE (`runHandlers`), or SSR stream (`runRender`); first-class `Signal` reactivity, compile-checked HTML | +| Module | JVM | JS | Native | WASM | Identity | +| ------------------------------------------------------ | --- | --- | ------ | ---- | --------------------------------------------------------------------------------------------------------- | +| [kyo-parse](kyo-parse/README.md) | ✅ | ✅ | ✅ | ✅ | Parser combinators in the effect row; supports dual-input-type parsers (e.g. `Parse[Char] & Parse[Int]`) | +| [kyo-flow](kyo-flow/README.md) | ✅ | ✅ | ✅ | ✅ | Durable workflow engine (Temporal/Cadence/ZIO-Flow space); value-replay execution, auto-generated REST | +| [kyo-pod](kyo-pod/README.md) | ✅ | ✅ | ✅ | ✅ | Docker and Podman client cross-compiled to JVM/JS/Native/WASM, streaming logs/stats, scope-managed cleanup | +| [kyo-aeron](kyo-aeron/README.md) | ✅ | ❌ | ❌ | ❌ | Typed pub/sub on Aeron: shared-memory IPC, UDP unicast, UDP multicast through one `Topic` API | +| [kyo-browser](kyo-browser/README.md) | ✅ | ✅ | ✅ | ✅ | Browser automation over Chrome DevTools Protocol; settlement-aware actions, `readableContent` as Markdown | +| [kyo-ui](kyo-ui/README.md) | ✅ | ✅ | ✅ | ✅ | Web UIs as pure values: one `UI` runs as a Scala.js DOM app (`runMount`), server HTML-over-SSE (`runHandlers`), or SSR stream (`runRender`); first-class `Signal` reactivity, compile-checked HTML | ### Observability In-process metrics and tracing registry, OTLP exporter that activates from `OTEL_EXPORTER_OTLP_ENDPOINT`, and two bridges from `kyo.Log` to the JDK or SLF4J logging APIs. -| Module | JVM | JS | Native | Identity | -| -------------------------------------------------------- | --- | --- | ------ | --------------------------------------------------------------------------------------------------------- | -| [kyo-stats-registry](kyo-stats-registry/README.md) | ✅ | ✅ | ✅ | Process-global registry; counters / gauges / counter-gauges / histograms; `TraceExporter` SPI | -| [kyo-stats-otlp](kyo-stats-otlp/README.md) | ✅ | ✅ | ✅ | Zero-code OTLP/HTTP+JSON exporter; W3C `traceparent` propagation auto-installed on kyo-http | -| [kyo-logging-jpl](kyo-logging-jpl/README.md) | ✅ | ❌ | ❌ | Bridge `kyo.Log` to `java.lang.System.Logger` (JEP 264, JDK 9+); zero third-party deps | -| [kyo-logging-slf4j](kyo-logging-slf4j/README.md) | ✅ | ❌ | ❌ | Bridge `kyo.Log` to any SLF4J binding the host application already configures (Logback, Log4j 2, etc.) | +| Module | JVM | JS | Native | WASM | Identity | +| -------------------------------------------------------- | --- | --- | ------ | ---- | --------------------------------------------------------------------------------------------------------- | +| [kyo-stats-registry](kyo-stats-registry/README.md) | ✅ | ✅ | ✅ | ✅ | Process-global registry; counters / gauges / counter-gauges / histograms; `TraceExporter` SPI | +| [kyo-stats-otlp](kyo-stats-otlp/README.md) | ✅ | ✅ | ✅ | ✅ | Zero-code OTLP/HTTP+JSON exporter; W3C `traceparent` propagation auto-installed on kyo-http | +| [kyo-logging-jpl](kyo-logging-jpl/README.md) | ✅ | ❌ | ❌ | ❌ | Bridge `kyo.Log` to `java.lang.System.Logger` (JEP 264, JDK 9+); zero third-party deps | +| [kyo-logging-slf4j](kyo-logging-slf4j/README.md) | ✅ | ❌ | ❌ | ❌ | Bridge `kyo.Log` to any SLF4J binding the host application already configures (Logback, Log4j 2, etc.) | ### Interop with other effect stacks Bidirectional bridges to neighbouring effect systems. `kyo-compat` is the special case: write a library once, ship it to six runtimes. -| Module | JVM | JS | Native | Identity | -| -------------------------------------------------------- | --- | --- | ------ | --------------------------------------------------------------------------------------------------------- | -| [kyo-cats](kyo-cats/README.md) | ✅ | ✅ | ❌ | Two-method bridge between Kyo and `cats.effect.IO`, with bidirectional cancellation | -| [kyo-zio](kyo-zio/README.md) | ✅ | ✅ | ✅ | Three-object bridge: `ZIOs` (effects), `ZStreams` (streams), `ZLayers` (layers) | -| [kyo-zio-test](kyo-zio-test/README.md) | ✅ | ✅ | ✅ | Write `zio-test` `Spec`s whose bodies are Kyo computations (`KyoSpecDefault`, `KyoSpecAbstract`) | -| [kyo-reactive-streams](kyo-reactive-streams/README.md) | ✅ | ❌ | ❌ | Bidirectional bridge between Kyo `Stream` and `Publisher`/`Subscriber`; verified against the TCK | -| [kyo-compat](kyo-compat/README.md) | ✅ | ✅* | ✅* | Library-author API: write once against `kyo.compat.*`, ship to ZIO, CE, Kyo, Future, Twitter Future, Ox | +| Module | JVM | JS | Native | WASM | Identity | +| -------------------------------------------------------- | --- | --- | ------ | ---- | --------------------------------------------------------------------------------------------------------- | +| [kyo-cats](kyo-cats/README.md) | ✅ | ✅ | ❌ | ❌ | Two-method bridge between Kyo and `cats.effect.IO`, with bidirectional cancellation | +| [kyo-zio](kyo-zio/README.md) | ✅ | ✅ | ✅ | ✅ | Three-object bridge: `ZIOs` (effects), `ZStreams` (streams), `ZLayers` (layers) | +| [kyo-zio-test](kyo-zio-test/README.md) | ✅ | ✅ | ✅ | ✅ | Write `zio-test` `Spec`s whose bodies are Kyo computations (`KyoSpecDefault`, `KyoSpecAbstract`) | +| [kyo-reactive-streams](kyo-reactive-streams/README.md) | ✅ | ✅ | ✅ | ✅ | Bidirectional bridge between Kyo `Stream` and `Publisher`/`Subscriber`; verified against the TCK | +| [kyo-compat](kyo-compat/README.md) | ✅ | ✅* | ✅* | ✅* | Library-author API: write once against `kyo.compat.*`, ship to ZIO, CE, Kyo, Future, Twitter Future, Ox | -*kyo-compat platform support depends on the runtime binding (-kyo / -future / -zio: JVM+JS+Native; -ce: JVM+JS; -ox / -twitter-future: JVM). +*kyo-compat platform support depends on the runtime binding (-kyo / -future / -zio: JVM+JS+Native+WASM; -ce: JVM+JS; -ox / -twitter-future: JVM). ### Scheduler embedding for other runtimes Replace the host runtime's executors with Kyo's adaptive work-stealing scheduler. One pool covers compute and blocking work, with admission control and CPU-based blocking detection; no application code change beyond a one-line swap. -| Module | JVM | JS | Native | Identity | -| ------------------------------------------------------------ | --- | --- | ------ | --------------------------------------------------------------------------------------------------------- | -| [kyo-scheduler-cats](kyo-scheduler-cats/README.md) | ✅ | ❌ | ❌ | Drop-in `IORuntime` replacement: `extends KyoSchedulerIOApp` or `import KyoSchedulerIORuntime.global` | -| [kyo-scheduler-finagle](kyo-scheduler-finagle/README.md) | ✅ | ❌ | ❌ | Twitter Finagle: activated by `-Dcom.twitter.finagle.exp.scheduler=kyo` (Scala 2.13 only) | -| [kyo-scheduler-pekko](kyo-scheduler-pekko/README.md) | ✅ | ❌ | ❌ | Pekko: one HOCON line replaces any dispatcher's executor | -| [kyo-scheduler-zio](kyo-scheduler-zio/README.md) | ✅ | ❌ | ✅ | ZIO: `extends KyoSchedulerZIOAppDefault` or `KyoSchedulerZIORuntime.default` standalone | +| Module | JVM | JS | Native | WASM | Identity | +| ------------------------------------------------------------ | --- | --- | ------ | ---- | --------------------------------------------------------------------------------------------------------- | +| [kyo-scheduler-cats](kyo-scheduler-cats/README.md) | ✅ | ❌ | ❌ | ❌ | Drop-in `IORuntime` replacement: `extends KyoSchedulerIOApp` or `import KyoSchedulerIORuntime.global` | +| [kyo-scheduler-finagle](kyo-scheduler-finagle/README.md) | ✅ | ❌ | ❌ | ❌ | Twitter Finagle: activated by `-Dcom.twitter.finagle.exp.scheduler=kyo` (Scala 2.13 only) | +| [kyo-scheduler-pekko](kyo-scheduler-pekko/README.md) | ✅ | ❌ | ❌ | ❌ | Pekko: one HOCON line replaces any dispatcher's executor | +| [kyo-scheduler-zio](kyo-scheduler-zio/README.md) | ✅ | ❌ | ✅ | ❌ | ZIO: `extends KyoSchedulerZIOAppDefault` or `KyoSchedulerZIORuntime.default` standalone | ### Tooling CLI-parser bridge, README example validation, runnable end-to-end programs, and the cross-runtime benchmark suite. -| Module | JVM | JS | Native | Identity | -| -------------------------------------------- | --- | --- | ------ | --------------------------------------------------------------------------------------------------------- | -| [kyo-case-app](kyo-case-app/README.md) | ✅ | ✅ | ✅ | Bridge case-app annotation-driven CLI parsing into a Kyo `run { options => ... }` entrypoint | -| [kyo-doctest](kyo-doctest/README.md) | ✅ | ❌ | ❌ | Validates Markdown code blocks against the Scala 3 compiler; sbt plugin runs them on `sbt doctest` | -| [kyo-examples](kyo-examples) | ✅ | ❌ | ❌ | Two runnable programs: a ledger HTTP service and an N-queens solver (run with `sbt`) | -| [kyo-bench](kyo-bench) | ✅ | ❌ | ❌ | JMH suite with side-by-side Kyo / Cats Effect / ZIO implementations for each scenario | +| Module | JVM | JS | Native | WASM | Identity | +| -------------------------------------------- | --- | --- | ------ | ---- | --------------------------------------------------------------------------------------------------------- | +| [kyo-case-app](kyo-case-app/README.md) | ✅ | ✅ | ✅ | ✅ | Bridge case-app annotation-driven CLI parsing into a Kyo `run { options => ... }` entrypoint | +| [kyo-doctest](kyo-doctest/README.md) | ✅ | ❌ | ❌ | ❌ | Validates Markdown code blocks against the Scala 3 compiler; sbt plugin runs them on `sbt doctest` | +| [kyo-examples](kyo-examples) | ✅ | ❌ | ❌ | ❌ | Two runnable programs: a ledger HTTP service and an N-queens solver (run with `sbt`) | +| [kyo-bench](kyo-bench) | ✅ | ❌ | ❌ | ❌ | JMH suite with side-by-side Kyo / Cats Effect / ZIO implementations for each scenario | ## Getting Started @@ -344,7 +361,7 @@ libraryDependencies ++= Seq( ) ``` -Use `%%` for JVM and Scala Native, `%%%` for Scala.js cross-compilation. See the [Modules](#modules) tables above for platform support per module. Replace `` with: ![Version](https://img.shields.io/maven-central/v/io.getkyo/kyo-core_3) +Use `%%` for JVM and Scala Native, `%%%` for the Scala.js and WebAssembly backends. See the [Modules](#modules) tables above for platform support per module. Replace `` with: ![Version](https://img.shields.io/maven-central/v/io.getkyo/kyo-core_3) A first read-through of [`kyo-core/README.md`](kyo-core/README.md) covers `Sync`, `Async`, `Scope`, `Fiber`, `KyoApp` (the entrypoint trait that discharges the effect row your `main` body produces), and the standard concurrent primitives. The natural follow-up for an application developer is [`kyo-http`](kyo-http/README.md). From there, drop into the rest of the module map above as your application grows. Worked end-to-end programs live in [`kyo-examples`](kyo-examples). diff --git a/build.sbt b/build.sbt index 30deed2ad0..886520640e 100644 --- a/build.sbt +++ b/build.sbt @@ -1,3 +1,4 @@ +import WasmCrossProject.* import com.github.sbt.git.SbtGit.GitKeys.useConsoleForROGit import org.scalajs.jsenv.nodejs.* import org.typelevel.scalacoptions.ScalacOption @@ -141,6 +142,7 @@ Global / onLoad := { case "JVM" => kyoJVM case "JS" => kyoJS case "NATIVE" => kyoNative + case "WASM" => kyoWasm case platform => throw new IllegalArgumentException("Invalid platform: " + platform) } @@ -326,8 +328,45 @@ lazy val kyoNative = project `kyo-compat-zio`.native ) +// WebAssembly aggregator (mirrors kyoJS). +lazy val kyoWasm = project + .in(file("wasm")) + .settings( + name := "kyoWasm", + `kyo-settings` + ) + .disablePlugins(MimaPlugin, KyoDoctestPlugin) + .aggregate( + `kyo-config`.wasm, + `kyo-stats-registry`.wasm, + `kyo-data`.wasm, + `kyo-kernel`.wasm, + `kyo-prelude`.wasm, + `kyo-parse`.wasm, + `kyo-schema`.wasm, + `kyo-scheduler`.wasm, + `kyo-core`.wasm, + `kyo-direct`.wasm, + `kyo-stm`.wasm, + `kyo-combinators`.wasm, + `kyo-actor`.wasm, + `kyo-reactive-streams`.wasm, + `kyo-zio`.wasm, + `kyo-zio-test`.wasm, + `kyo-case-app`.wasm, + `kyo-compat-future`.wasm, + `kyo-compat-kyo`.wasm, + `kyo-compat-zio`.wasm, + `kyo-http`.wasm, + `kyo-stats-otlp`.wasm, + `kyo-flow`.wasm, + `kyo-pod`.wasm, + `kyo-browser`.wasm, + `kyo-ui`.wasm + ) + lazy val `kyo-scheduler` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .dependsOn(`kyo-stats-registry`) @@ -347,6 +386,12 @@ lazy val `kyo-scheduler` = `js-settings`, libraryDependencies += "org.scala-js" %%% "scala-js-macrotask-executor" % "1.1.1" ) + .wasmSettings( + `wasm-settings`, + // WASM uses the same single-threaded, event-loop scheduler as JS, which drives + // execution through the macrotask executor. + libraryDependencies += "org.scala-js" %%% "scala-js-macrotask-executor" % "1.1.1" + ) lazy val `kyo-scheduler-zio` = sbtcrossproject.CrossProject("kyo-scheduler-zio", file("kyo-scheduler-zio"))(JVMPlatform, NativePlatform) .withoutSuffixFor(JVMPlatform) @@ -431,7 +476,7 @@ lazy val `kyo-scheduler-finagle` = .dependsOn(`kyo-scheduler`) lazy val `kyo-data` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .dependsOn(`kyo-stats-registry`) @@ -444,9 +489,10 @@ lazy val `kyo-data` = .jvmSettings(mimaCheck(false)) .nativeSettings(`native-settings`) .jsSettings(`js-settings`) + .wasmSettings(`wasm-settings`) lazy val `kyo-kernel` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .dependsOn(`kyo-data`) @@ -462,9 +508,10 @@ lazy val `kyo-kernel` = )) .nativeSettings(`native-settings`) .jsSettings(`js-settings`) + .wasmSettings(`wasm-settings`) lazy val `kyo-prelude` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .dependsOn(`kyo-kernel`) @@ -477,9 +524,10 @@ lazy val `kyo-prelude` = .jvmSettings(mimaCheck(false)) .nativeSettings(`native-settings`) .jsSettings(`js-settings`) + .wasmSettings(`wasm-settings`) lazy val `kyo-parse` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .dependsOn(`kyo-prelude`) @@ -488,9 +536,10 @@ lazy val `kyo-parse` = .jvmSettings(mimaCheck(false)) .nativeSettings(`native-settings`) .jsSettings(`js-settings`) + .wasmSettings(`wasm-settings`) lazy val `kyo-schema` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .dependsOn(`kyo-data` % "test->test;compile->compile") @@ -499,9 +548,10 @@ lazy val `kyo-schema` = .jvmSettings(mimaCheck(false)) .nativeSettings(`native-settings`) .jsSettings(`js-settings`) + .wasmSettings(`wasm-settings`) lazy val `kyo-core` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .dependsOn(`kyo-scheduler`) @@ -517,6 +567,11 @@ lazy val `kyo-core` = libraryDependencies += ("org.scala-js" %%% "scalajs-java-logging" % "1.0.0").cross(CrossVersion.for3Use2_13), scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) } ) + .wasmSettings( + `wasm-settings`, + // Same java.util.logging shim as JS. + libraryDependencies += ("org.scala-js" %%% "scalajs-java-logging" % "1.0.0").cross(CrossVersion.for3Use2_13) + ) lazy val `kyo-offheap` = crossProject(JVMPlatform, NativePlatform) @@ -535,7 +590,7 @@ lazy val `kyo-offheap` = ) lazy val `kyo-direct` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-direct")) @@ -554,9 +609,10 @@ lazy val `kyo-direct` = )) .nativeSettings(`native-settings`) .jsSettings(`js-settings`) + .wasmSettings(`wasm-settings`) lazy val `kyo-stm` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-stm")) @@ -565,9 +621,10 @@ lazy val `kyo-stm` = .jvmSettings(mimaCheck(false)) .nativeSettings(`native-settings`) .jsSettings(`js-settings`) + .wasmSettings(`wasm-settings`) lazy val `kyo-actor` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-actor")) @@ -576,6 +633,7 @@ lazy val `kyo-actor` = .jvmSettings(mimaCheck(false)) .nativeSettings(`native-settings`) .jsSettings(`js-settings`) + .wasmSettings(`wasm-settings`) lazy val `kyo-logging-jpl` = crossProject(JVMPlatform) @@ -600,7 +658,7 @@ lazy val `kyo-logging-slf4j` = .jvmSettings(mimaCheck(false)) lazy val `kyo-stats-registry` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .dependsOn(`kyo-config`) @@ -613,9 +671,10 @@ lazy val `kyo-stats-registry` = .jvmSettings(mimaCheck(false)) .nativeSettings(`native-settings`) .jsSettings(`js-settings`) + .wasmSettings(`wasm-settings`) lazy val `kyo-config` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-config")) @@ -627,9 +686,10 @@ lazy val `kyo-config` = .jvmSettings(mimaCheck(false)) .nativeSettings(`native-settings`) .jsSettings(`js-settings`) + .wasmSettings(`wasm-settings`) lazy val `kyo-stats-otlp` = - crossProject(JVMPlatform, JSPlatform, NativePlatform) + crossProject(JVMPlatform, JSPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-stats-otlp")) @@ -643,9 +703,10 @@ lazy val `kyo-stats-otlp` = `js-settings`, scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) } ) + .wasmSettings(`wasm-settings`) lazy val `kyo-reactive-streams` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-reactive-streams")) @@ -663,6 +724,7 @@ lazy val `kyo-reactive-streams` = ) .nativeSettings(`native-settings`) .jsSettings(`js-settings`) + .wasmSettings(`wasm-settings`) lazy val `kyo-aeron` = crossProject(JVMPlatform) @@ -688,7 +750,7 @@ lazy val `kyo-aeron` = .jvmSettings(mimaCheck(false)) lazy val `kyo-http` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-http")) @@ -707,9 +769,10 @@ lazy val `kyo-http` = `native-settings`, `openssl-native-settings` ) + .wasmSettings(`wasm-settings`) lazy val `kyo-flow` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-flow")) @@ -722,6 +785,7 @@ lazy val `kyo-flow` = `js-settings`, scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) } ) + .wasmSettings(`wasm-settings`) lazy val `kyo-caliban` = crossProject(JVMPlatform) @@ -740,7 +804,7 @@ lazy val `kyo-caliban` = .jvmSettings(mimaCheck(false)) lazy val `kyo-zio-test` = - crossProject(JVMPlatform, JSPlatform, NativePlatform) + crossProject(JVMPlatform, JSPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-zio-test")) @@ -759,9 +823,10 @@ lazy val `kyo-zio-test` = `native-settings` ) .jvmSettings(mimaCheck(false)) + .wasmSettings(`wasm-settings`) lazy val `kyo-zio` = - crossProject(JVMPlatform, JSPlatform, NativePlatform) + crossProject(JVMPlatform, JSPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-zio")) @@ -778,7 +843,9 @@ lazy val `kyo-zio` = `native-settings` ) .jvmSettings(mimaCheck(false)) + .wasmSettings(`wasm-settings`) +// TODO(wasm): re-enable once cats-effect supports WASM (typelevel/cats-effect#4608). lazy val `kyo-cats` = crossProject(JSPlatform, JVMPlatform) .withoutSuffixFor(JVMPlatform) @@ -795,7 +862,7 @@ lazy val `kyo-cats` = .jvmSettings(mimaCheck(false)) lazy val `kyo-compat-future` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-compat/bindings/future")) @@ -829,9 +896,10 @@ lazy val `kyo-compat-future` = .jvmConfigure(_.disablePlugins(KyoDoctestPlugin)) .jsSettings(`js-settings`, mimaCheck(false)) .nativeSettings(`native-settings`, mimaCheck(false)) + .wasmSettings(`wasm-settings`) lazy val `kyo-compat-kyo` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-compat/bindings/kyo")) @@ -860,9 +928,10 @@ lazy val `kyo-compat-kyo` = // kyo-compat README lives at kyo-compat/ (three levels up from jvm/) doctestSources := Seq(baseDirectory.value / ".." / ".." / ".." / "README.md") )) + .wasmSettings(`wasm-settings`) lazy val `kyo-compat-zio` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-compat/bindings/zio")) @@ -893,7 +962,9 @@ lazy val `kyo-compat-zio` = } ) .jvmConfigure(_.disablePlugins(KyoDoctestPlugin)) + .wasmSettings(`wasm-settings`) +// TODO(wasm): re-enable with cats-effect WASM support; depends on cats-effect (see kyo-cats). lazy val `kyo-compat-ce` = crossProject(JSPlatform, JVMPlatform) .withoutSuffixFor(JVMPlatform) @@ -1007,7 +1078,7 @@ lazy val `kyo-compat-tests` = ) lazy val `kyo-combinators` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-combinators")) @@ -1016,9 +1087,10 @@ lazy val `kyo-combinators` = .jsSettings(`js-settings`) .nativeSettings(`native-settings`) .jvmSettings(mimaCheck(false)) + .wasmSettings(`wasm-settings`) lazy val `kyo-case-app` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-case-app")) @@ -1030,9 +1102,10 @@ lazy val `kyo-case-app` = .jsSettings(`js-settings`) .nativeSettings(`native-settings`) .jvmSettings(mimaCheck(false)) + .wasmSettings(`wasm-settings`) lazy val `kyo-pod` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-pod")) @@ -1126,9 +1199,10 @@ lazy val `kyo-pod` = `js-settings`, scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) } ) + .wasmSettings(`wasm-settings`) lazy val `kyo-browser` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-browser")) @@ -1181,9 +1255,10 @@ lazy val `kyo-browser` = `js-settings`, scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) } ) + .wasmSettings(`wasm-settings`) lazy val `kyo-ui` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .in(file("kyo-ui")) @@ -1231,6 +1306,10 @@ lazy val `kyo-ui` = libraryDependencies += "org.scala-js" %%% "scalajs-dom" % "2.8.0", scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) } ) + .wasmSettings( + `wasm-settings`, + libraryDependencies += "org.scala-js" %%% "scalajs-dom" % "2.8.0" + ) lazy val `kyo-examples` = crossProject(JVMPlatform) @@ -1412,6 +1491,26 @@ lazy val `js-settings` = Seq( libraryDependencies += "io.github.cquiroz" %%% "scala-java-time" % "2.6.0" ) +// WASM rows are Scala.js compilations: same scala-java-time stand-in for the JDK time APIs, +// emitted as an ESModule (set by WasmPlatform). They require Node 24+: it defaults to V8's +// Turboshaft Wasm pipeline, under which the generated WasmGC code compiles correctly. The legacy +// TurboFan pipeline on Node 22/23 miscompiled it; Node 23 is EOL, and Node 24 made Turboshaft the +// default and removed the --turboshaft-wasm opt-in flag (passing it there is a startup error). +lazy val `wasm-settings` = Seq( + Compile / doc / sources := Seq.empty, + fork := false, + bspEnabled := false, + Test / parallelExecution := false, + jsEnv := new NodeJSEnv( + NodeJSEnv.Config().withArgs(List( + "--max_old_space_size=5120", + // exnref: the WASM backend emits exnref exception-handling opcodes Node needs to load it. + "--experimental-wasm-exnref" + )) + ), + libraryDependencies += "io.github.cquiroz" %%% "scala-java-time" % "2.6.0" +) + def scalacOptionToken(proposedScalacOption: ScalacOption) = scalacOptionTokens(Set(proposedScalacOption)) diff --git a/kyo-browser/js/src/main/scala/kyo/internal/BrowserLauncherPlatform.scala b/kyo-browser/js-wasm/src/main/scala/kyo/internal/BrowserLauncherPlatform.scala similarity index 100% rename from kyo-browser/js/src/main/scala/kyo/internal/BrowserLauncherPlatform.scala rename to kyo-browser/js-wasm/src/main/scala/kyo/internal/BrowserLauncherPlatform.scala diff --git a/kyo-browser/js/src/test/scala/kyo/internal/BrowserLauncherPlatformTest.scala b/kyo-browser/js-wasm/src/test/scala/kyo/internal/BrowserLauncherPlatformTest.scala similarity index 100% rename from kyo-browser/js/src/test/scala/kyo/internal/BrowserLauncherPlatformTest.scala rename to kyo-browser/js-wasm/src/test/scala/kyo/internal/BrowserLauncherPlatformTest.scala diff --git a/kyo-compat/bindings/future/js/src/main/scala/kyo/compat/internal/CompatScheduler.scala b/kyo-compat/bindings/future/js-wasm/src/main/scala/kyo/compat/internal/CompatScheduler.scala similarity index 100% rename from kyo-compat/bindings/future/js/src/main/scala/kyo/compat/internal/CompatScheduler.scala rename to kyo-compat/bindings/future/js-wasm/src/main/scala/kyo/compat/internal/CompatScheduler.scala diff --git a/kyo-core/js/src/main/scala/kyo/AsyncStubs.scala b/kyo-core/js-wasm/src/main/scala/kyo/AsyncStubs.scala similarity index 100% rename from kyo-core/js/src/main/scala/kyo/AsyncStubs.scala rename to kyo-core/js-wasm/src/main/scala/kyo/AsyncStubs.scala diff --git a/kyo-core/js/src/main/scala/kyo/VarHandle.scala b/kyo-core/js-wasm/src/main/scala/kyo/VarHandle.scala similarity index 100% rename from kyo-core/js/src/main/scala/kyo/VarHandle.scala rename to kyo-core/js-wasm/src/main/scala/kyo/VarHandle.scala diff --git a/kyo-core/js/src/main/scala/kyo/addersStubs.scala b/kyo-core/js-wasm/src/main/scala/kyo/addersStubs.scala similarity index 100% rename from kyo-core/js/src/main/scala/kyo/addersStubs.scala rename to kyo-core/js-wasm/src/main/scala/kyo/addersStubs.scala diff --git a/kyo-core/js/src/main/scala/kyo/hubsStubs.scala b/kyo-core/js-wasm/src/main/scala/kyo/hubsStubs.scala similarity index 100% rename from kyo-core/js/src/main/scala/kyo/hubsStubs.scala rename to kyo-core/js-wasm/src/main/scala/kyo/hubsStubs.scala diff --git a/kyo-core/js/src/main/scala/kyo/internal/AsyncPlatformSpecific.scala b/kyo-core/js-wasm/src/main/scala/kyo/internal/AsyncPlatformSpecific.scala similarity index 100% rename from kyo-core/js/src/main/scala/kyo/internal/AsyncPlatformSpecific.scala rename to kyo-core/js-wasm/src/main/scala/kyo/internal/AsyncPlatformSpecific.scala diff --git a/kyo-core/js/src/main/scala/kyo/internal/KyoAppPlatformSpecific.scala b/kyo-core/js-wasm/src/main/scala/kyo/internal/KyoAppPlatformSpecific.scala similarity index 100% rename from kyo-core/js/src/main/scala/kyo/internal/KyoAppPlatformSpecific.scala rename to kyo-core/js-wasm/src/main/scala/kyo/internal/KyoAppPlatformSpecific.scala diff --git a/kyo-core/js/src/main/scala/kyo/internal/KyoAppRunnerPlatform.scala b/kyo-core/js-wasm/src/main/scala/kyo/internal/KyoAppRunnerPlatform.scala similarity index 100% rename from kyo-core/js/src/main/scala/kyo/internal/KyoAppRunnerPlatform.scala rename to kyo-core/js-wasm/src/main/scala/kyo/internal/KyoAppRunnerPlatform.scala diff --git a/kyo-core/js/src/main/scala/kyo/internal/OSSignalPlatformSpecific.scala b/kyo-core/js-wasm/src/main/scala/kyo/internal/OSSignalPlatformSpecific.scala similarity index 100% rename from kyo-core/js/src/main/scala/kyo/internal/OSSignalPlatformSpecific.scala rename to kyo-core/js-wasm/src/main/scala/kyo/internal/OSSignalPlatformSpecific.scala diff --git a/kyo-core/js/src/main/scala/kyo/internal/PathPlatformSpecific.scala b/kyo-core/js-wasm/src/main/scala/kyo/internal/PathPlatformSpecific.scala similarity index 99% rename from kyo-core/js/src/main/scala/kyo/internal/PathPlatformSpecific.scala rename to kyo-core/js-wasm/src/main/scala/kyo/internal/PathPlatformSpecific.scala index 8c3fe485c0..4434ba672b 100644 --- a/kyo-core/js/src/main/scala/kyo/internal/PathPlatformSpecific.scala +++ b/kyo-core/js-wasm/src/main/scala/kyo/internal/PathPlatformSpecific.scala @@ -70,6 +70,12 @@ private[kyo] object NodeOs extends js.Object: def platform(): String = js.native end NodeOs +@js.native +@JSImport("node:crypto", JSImport.Namespace) +private[kyo] object NodeCrypto extends js.Object: + def randomBytes(size: Int): js.Dynamic = js.native +end NodeCrypto + // --- Exception translation helpers --- private[kyo] object NodeError: @@ -653,7 +659,7 @@ abstract private[kyo] class PathPlatformSpecific extends PathDirectories: /** Generates a random identifier using the Node.js crypto module (avoids java.security.SecureRandom). */ private def randomId(): String = - js.Dynamic.global.require("node:crypto").randomBytes(16).applyDynamic("toString")("hex").asInstanceOf[String] + NodeCrypto.randomBytes(16).applyDynamic("toString")("hex").asInstanceOf[String] private[kyo] def envOrEmpty(name: String): String = val v = js.Dynamic.global.process.env.selectDynamic(name) diff --git a/kyo-core/js/src/main/scala/kyo/internal/ProcessPlatformSpecific.scala b/kyo-core/js-wasm/src/main/scala/kyo/internal/ProcessPlatformSpecific.scala similarity index 98% rename from kyo-core/js/src/main/scala/kyo/internal/ProcessPlatformSpecific.scala rename to kyo-core/js-wasm/src/main/scala/kyo/internal/ProcessPlatformSpecific.scala index 05ecf344a0..92ca2ce2a1 100644 --- a/kyo-core/js/src/main/scala/kyo/internal/ProcessPlatformSpecific.scala +++ b/kyo-core/js-wasm/src/main/scala/kyo/internal/ProcessPlatformSpecific.scala @@ -18,6 +18,17 @@ private[kyo] object NodeChildProcess extends js.Object: js.native end NodeChildProcess +// Namespace imports accessed dynamically. @JSImport compiles to require() under CommonJS and +// to import under ESModule, so these work on both the JS and WASM backends, unlike a +// js.Dynamic.global.require call (require is not a global in a Node ES module). +@js.native +@JSImport("node:fs", JSImport.Namespace) +private[kyo] object NodeFsModule extends js.Object + +@js.native +@JSImport("node:path", JSImport.Namespace) +private[kyo] object NodePathModule extends js.Object + @js.native private[kyo] trait NodeChildProcessInstance extends js.Object: def pid: Int = js.native @@ -456,8 +467,8 @@ final private[kyo] class NodeCommandUnsafe( else val cmd = args.head try - val fs = js.Dynamic.global.require("node:fs") - val nodePath = js.Dynamic.global.require("node:path") + val fs = NodeFsModule.asInstanceOf[js.Dynamic] + val nodePath = NodePathModule.asInstanceOf[js.Dynamic] val X_OK = fs.constants.X_OK.asInstanceOf[Int] // Helper: check if a file exists and is executable def isExec(p: String): Boolean = @@ -526,7 +537,7 @@ final private[kyo] class NodeCommandUnsafe( // synchronously (Node.js would otherwise fire an asynchronous 'error' event). val wdError = workDir.flatMap { path => val dirPath = path.unsafe.show - val exists = js.Dynamic.global.require("node:fs").existsSync(dirPath).asInstanceOf[Boolean] + val exists = NodeFsModule.asInstanceOf[js.Dynamic].existsSync(dirPath).asInstanceOf[Boolean] if !exists then Present(WorkingDirectoryNotFoundException(path)) else Absent } diff --git a/kyo-core/js/src/main/scala/kyo/internal/SystemPlatformSpecific.scala b/kyo-core/js-wasm/src/main/scala/kyo/internal/SystemPlatformSpecific.scala similarity index 100% rename from kyo-core/js/src/main/scala/kyo/internal/SystemPlatformSpecific.scala rename to kyo-core/js-wasm/src/main/scala/kyo/internal/SystemPlatformSpecific.scala diff --git a/kyo-core/js/src/main/scala/kyo/scheduler/IOPromisePlatformSpecific.scala b/kyo-core/js-wasm/src/main/scala/kyo/scheduler/IOPromisePlatformSpecific.scala similarity index 100% rename from kyo-core/js/src/main/scala/kyo/scheduler/IOPromisePlatformSpecific.scala rename to kyo-core/js-wasm/src/main/scala/kyo/scheduler/IOPromisePlatformSpecific.scala diff --git a/kyo-core/js/src/main/scala/kyo/stats/internal/JSServiceLoaderRegistry.scala b/kyo-core/js-wasm/src/main/scala/kyo/stats/internal/JSServiceLoaderRegistry.scala similarity index 100% rename from kyo-core/js/src/main/scala/kyo/stats/internal/JSServiceLoaderRegistry.scala rename to kyo-core/js-wasm/src/main/scala/kyo/stats/internal/JSServiceLoaderRegistry.scala diff --git a/kyo-core/js/src/main/scala/kyo/stats/internal/ServiceLoader.scala b/kyo-core/js-wasm/src/main/scala/kyo/stats/internal/ServiceLoader.scala similarity index 100% rename from kyo-core/js/src/main/scala/kyo/stats/internal/ServiceLoader.scala rename to kyo-core/js-wasm/src/main/scala/kyo/stats/internal/ServiceLoader.scala diff --git a/kyo-core/js/src/main/scala/kyo/timersSubs.scala b/kyo-core/js-wasm/src/main/scala/kyo/timersSubs.scala similarity index 100% rename from kyo-core/js/src/main/scala/kyo/timersSubs.scala rename to kyo-core/js-wasm/src/main/scala/kyo/timersSubs.scala diff --git a/kyo-data/js/src/main/scala/kyo/internal/MpmcUnboundedUnsafeQueue.scala b/kyo-data/js-wasm/src/main/scala/kyo/internal/MpmcUnboundedUnsafeQueue.scala similarity index 100% rename from kyo-data/js/src/main/scala/kyo/internal/MpmcUnboundedUnsafeQueue.scala rename to kyo-data/js-wasm/src/main/scala/kyo/internal/MpmcUnboundedUnsafeQueue.scala diff --git a/kyo-data/js/src/main/scala/kyo/internal/MpmcUnsafeQueue.scala b/kyo-data/js-wasm/src/main/scala/kyo/internal/MpmcUnsafeQueue.scala similarity index 100% rename from kyo-data/js/src/main/scala/kyo/internal/MpmcUnsafeQueue.scala rename to kyo-data/js-wasm/src/main/scala/kyo/internal/MpmcUnsafeQueue.scala diff --git a/kyo-data/js/src/main/scala/kyo/internal/MpscUnboundedUnsafeQueue.scala b/kyo-data/js-wasm/src/main/scala/kyo/internal/MpscUnboundedUnsafeQueue.scala similarity index 100% rename from kyo-data/js/src/main/scala/kyo/internal/MpscUnboundedUnsafeQueue.scala rename to kyo-data/js-wasm/src/main/scala/kyo/internal/MpscUnboundedUnsafeQueue.scala diff --git a/kyo-data/js/src/main/scala/kyo/internal/MpscUnsafeQueue.scala b/kyo-data/js-wasm/src/main/scala/kyo/internal/MpscUnsafeQueue.scala similarity index 100% rename from kyo-data/js/src/main/scala/kyo/internal/MpscUnsafeQueue.scala rename to kyo-data/js-wasm/src/main/scala/kyo/internal/MpscUnsafeQueue.scala diff --git a/kyo-data/js/src/main/scala/kyo/internal/SpmcUnboundedUnsafeQueue.scala b/kyo-data/js-wasm/src/main/scala/kyo/internal/SpmcUnboundedUnsafeQueue.scala similarity index 100% rename from kyo-data/js/src/main/scala/kyo/internal/SpmcUnboundedUnsafeQueue.scala rename to kyo-data/js-wasm/src/main/scala/kyo/internal/SpmcUnboundedUnsafeQueue.scala diff --git a/kyo-data/js/src/main/scala/kyo/internal/SpmcUnsafeQueue.scala b/kyo-data/js-wasm/src/main/scala/kyo/internal/SpmcUnsafeQueue.scala similarity index 100% rename from kyo-data/js/src/main/scala/kyo/internal/SpmcUnsafeQueue.scala rename to kyo-data/js-wasm/src/main/scala/kyo/internal/SpmcUnsafeQueue.scala diff --git a/kyo-data/js/src/main/scala/kyo/internal/SpscUnboundedUnsafeQueue.scala b/kyo-data/js-wasm/src/main/scala/kyo/internal/SpscUnboundedUnsafeQueue.scala similarity index 100% rename from kyo-data/js/src/main/scala/kyo/internal/SpscUnboundedUnsafeQueue.scala rename to kyo-data/js-wasm/src/main/scala/kyo/internal/SpscUnboundedUnsafeQueue.scala diff --git a/kyo-data/js/src/main/scala/kyo/internal/SpscUnsafeQueue.scala b/kyo-data/js-wasm/src/main/scala/kyo/internal/SpscUnsafeQueue.scala similarity index 100% rename from kyo-data/js/src/main/scala/kyo/internal/SpscUnsafeQueue.scala rename to kyo-data/js-wasm/src/main/scala/kyo/internal/SpscUnsafeQueue.scala diff --git a/kyo-data/js/src/main/scala/kyo/internal/UnboundedUnsafeQueueBase.scala b/kyo-data/js-wasm/src/main/scala/kyo/internal/UnboundedUnsafeQueueBase.scala similarity index 100% rename from kyo-data/js/src/main/scala/kyo/internal/UnboundedUnsafeQueueBase.scala rename to kyo-data/js-wasm/src/main/scala/kyo/internal/UnboundedUnsafeQueueBase.scala diff --git a/kyo-data/js/src/main/scala/kyo/internal/Platform.scala b/kyo-data/js/src/main/scala/kyo/internal/Platform.scala index cd74b8a85e..e2b926db29 100644 --- a/kyo-data/js/src/main/scala/kyo/internal/Platform.scala +++ b/kyo-data/js/src/main/scala/kyo/internal/Platform.scala @@ -7,8 +7,14 @@ object Platform: val executionContext: ExecutionContext = scala.concurrent.ExecutionContext.global inline def isJVM: Boolean = false inline def isJS = true + inline def isWasm: Boolean = false inline def isNative: Boolean = false inline def isDebugEnabled: Boolean = false + + // Frames a synchronous chain runs before the kernel suspends through Safepoint to stay + // stack-safe. 512 fits comfortably within Node's JS call stack. + inline def maxStackDepth: Int = 512 + def exit(code: Int): Unit = scala.scalajs.js.Dynamic.global.process.exitCode = code diff --git a/kyo-data/jvm/src/main/scala/kyo/internal/Platform.scala b/kyo-data/jvm/src/main/scala/kyo/internal/Platform.scala index 68e01d1f9b..7e8519e400 100644 --- a/kyo-data/jvm/src/main/scala/kyo/internal/Platform.scala +++ b/kyo-data/jvm/src/main/scala/kyo/internal/Platform.scala @@ -7,7 +7,13 @@ object Platform: val executionContext: ExecutionContext = scala.concurrent.ExecutionContext.global inline def isJVM: Boolean = true inline def isJS = false + inline def isWasm: Boolean = false inline def isNative: Boolean = false + + // Frames a synchronous chain runs before the kernel suspends through Safepoint to stay + // stack-safe. 512 fits comfortably within the default JVM thread stack. + inline def maxStackDepth: Int = 512 + val isDebugEnabled: Boolean = java.lang.management.ManagementFactory .getRuntimeMXBean() diff --git a/kyo-data/native/src/main/scala/kyo/internal/Platform.scala b/kyo-data/native/src/main/scala/kyo/internal/Platform.scala index 2b6c669e2d..835ff38a74 100644 --- a/kyo-data/native/src/main/scala/kyo/internal/Platform.scala +++ b/kyo-data/native/src/main/scala/kyo/internal/Platform.scala @@ -7,9 +7,16 @@ object Platform: val executionContext: ExecutionContext = scala.concurrent.ExecutionContext.global inline def isJVM: Boolean = false inline def isJS: Boolean = false + inline def isWasm: Boolean = false inline def isNative: Boolean = true inline def isDebugEnabled: Boolean = false - def exit(code: Int): Unit = java.lang.System.exit(code) + + // Frames a synchronous chain runs before the kernel suspends through Safepoint to stay + // stack-safe. Native frames are larger than the JVM's, so it uses the lower 256 bound + // (matching WASM) rather than 512. + inline def maxStackDepth: Int = 256 + + def exit(code: Int): Unit = java.lang.System.exit(code) // OS detection val isWindows: Boolean = scala.scalanative.meta.LinktimeInfo.isWindows diff --git a/kyo-data/wasm/src/main/scala/kyo/internal/Platform.scala b/kyo-data/wasm/src/main/scala/kyo/internal/Platform.scala new file mode 100644 index 0000000000..8e3d1720c8 --- /dev/null +++ b/kyo-data/wasm/src/main/scala/kyo/internal/Platform.scala @@ -0,0 +1,37 @@ +package kyo.internal + +import scala.concurrent.ExecutionContext +import scala.scalajs.js + +object Platform: + val executionContext: ExecutionContext = scala.concurrent.ExecutionContext.global + inline def isJVM: Boolean = false + // WASM shares the JS execution model (single-threaded, Node runtime), so isJS stays true: + // code that branches on isJS for single-threaded behavior must take the same path here. + // isWasm is the additive flag for the cases where WASM genuinely diverges from JS. + inline def isJS = true + inline def isWasm: Boolean = true + inline def isNative: Boolean = false + inline def isDebugEnabled: Boolean = false + + // Frames a synchronous chain runs before the kernel suspends through Safepoint to stay + // stack-safe. The Scala.js WebAssembly backend runs on a smaller call stack than the JS + // backend, so it uses a lower bound than the JVM/JS default of 512. + inline def maxStackDepth: Int = 256 + + def exit(code: Int): Unit = + scala.scalajs.js.Dynamic.global.process.exitCode = code + + // OS detection via Node.js + private val nodePlatform: String = + try js.Dynamic.global.process.platform.asInstanceOf[String] + catch case _: Throwable => "unknown" + + val isWindows: Boolean = nodePlatform == "win32" + val isMac: Boolean = nodePlatform == "darwin" + val isLinux: Boolean = nodePlatform == "linux" + + val fileSeparator: String = if isWindows then "\\" else "/" + val pathSeparator: String = if isWindows then ";" else ":" + val lineSeparator: String = if isWindows then "\r\n" else "\n" +end Platform diff --git a/kyo-doctest/plugin/src/main/scala/kyo/doctest/sbt/KyoDoctestPlugin.scala b/kyo-doctest/plugin/src/main/scala/kyo/doctest/sbt/KyoDoctestPlugin.scala index 900cd4f81c..356b442b51 100644 --- a/kyo-doctest/plugin/src/main/scala/kyo/doctest/sbt/KyoDoctestPlugin.scala +++ b/kyo-doctest/plugin/src/main/scala/kyo/doctest/sbt/KyoDoctestPlugin.scala @@ -120,10 +120,12 @@ object KyoDoctestPlugin extends AutoPlugin { import autoImport._ // Sub-directory names produced by sbt-crossproject for non-JVM platforms. - // The doctest task forks a JVM and would crash on scala-native or scala-js - // compiled .class files (UndefinedBehaviorError, NoClassDefFoundError), - // so the aggregate skips these. - private val nonJvmCrossDirs = Set("native", "js") + // The doctest task forks a JVM and would crash on scala-native, scala-js, or + // scala-js/WebAssembly compiled .class files (UndefinedBehaviorError, + // NoClassDefFoundError, "native JS type called on the JVM"), so the aggregate + // skips these. "wasm" is the Scala.js WebAssembly backend + // (WasmPlatform.identifier), in the same JVM-incompatible category as "js". + private val nonJvmCrossDirs = Set("native", "js", "wasm") private def projectsWithDoctest(state: State): Seq[ProjectRef] = { val structure = Project.extract(state).structure diff --git a/kyo-http/js/src/main/scala/java/security/SecureRandom.scala b/kyo-http/js-wasm/src/main/scala/java/security/SecureRandom.scala similarity index 100% rename from kyo-http/js/src/main/scala/java/security/SecureRandom.scala rename to kyo-http/js-wasm/src/main/scala/java/security/SecureRandom.scala diff --git a/kyo-http/js/src/main/scala/kyo/internal/HttpPlatformTransport.scala b/kyo-http/js-wasm/src/main/scala/kyo/internal/HttpPlatformTransport.scala similarity index 100% rename from kyo-http/js/src/main/scala/kyo/internal/HttpPlatformTransport.scala rename to kyo-http/js-wasm/src/main/scala/kyo/internal/HttpPlatformTransport.scala diff --git a/kyo-http/js/src/main/scala/kyo/internal/JsHandle.scala b/kyo-http/js-wasm/src/main/scala/kyo/internal/JsHandle.scala similarity index 100% rename from kyo-http/js/src/main/scala/kyo/internal/JsHandle.scala rename to kyo-http/js-wasm/src/main/scala/kyo/internal/JsHandle.scala diff --git a/kyo-http/js/src/main/scala/kyo/internal/JsIoDriver.scala b/kyo-http/js-wasm/src/main/scala/kyo/internal/JsIoDriver.scala similarity index 100% rename from kyo-http/js/src/main/scala/kyo/internal/JsIoDriver.scala rename to kyo-http/js-wasm/src/main/scala/kyo/internal/JsIoDriver.scala diff --git a/kyo-http/js/src/main/scala/kyo/internal/JsTransport.scala b/kyo-http/js-wasm/src/main/scala/kyo/internal/JsTransport.scala similarity index 95% rename from kyo-http/js/src/main/scala/kyo/internal/JsTransport.scala rename to kyo-http/js-wasm/src/main/scala/kyo/internal/JsTransport.scala index a8d564d3e2..3550d24c75 100644 --- a/kyo-http/js/src/main/scala/kyo/internal/JsTransport.scala +++ b/kyo-http/js-wasm/src/main/scala/kyo/internal/JsTransport.scala @@ -22,14 +22,14 @@ final private[kyo] class JsTransport private ( ) extends Transport[JsHandle]: def connect(host: String, port: Int)(using AllowUnsafe, Frame): Fiber.Unsafe[Connection[JsHandle], Abort[Closed]] = - val socket = js.Dynamic.global.require("net").connect(port, host) + val socket = HttpNet.asInstanceOf[js.Dynamic].connect(port, host) connectSocket(socket, host, port, tcpNoDelay = true, connectEvent = "connect") end connect def listen(host: String, port: Int, backlog: Int)( handler: Connection[JsHandle] => Unit < Async )(using AllowUnsafe, Frame): Fiber.Unsafe[Listener, Abort[Closed]] = - val server = js.Dynamic.global.require("net").createServer() + val server = HttpNet.asInstanceOf[js.Dynamic].createServer() listenServer(server, host, port, backlog, tcpNoDelay = true, connectionEvent = "connection", handler) end listen @@ -39,7 +39,7 @@ final private[kyo] class JsTransport private ( tls.sniHostname match case Present(sni) => opts.servername = sni case Absent => opts.servername = host - val socket = js.Dynamic.global.require("tls").connect(opts) + val socket = HttpTls.asInstanceOf[js.Dynamic].connect(opts) // TLS sockets emit "secureConnect" after handshake (not "connect" which fires on raw TCP) connectSocket(socket, host, port, tcpNoDelay = false, connectEvent = "secureConnect") end connect @@ -49,12 +49,12 @@ final private[kyo] class JsTransport private ( )(using AllowUnsafe, Frame): Fiber.Unsafe[Listener, Abort[Closed]] = val serverOpts = js.Dynamic.literal() tls.certChainPath match - case Present(p) => serverOpts.cert = js.Dynamic.global.require("fs").readFileSync(p, "utf8") + case Present(p) => serverOpts.cert = HttpFs.asInstanceOf[js.Dynamic].readFileSync(p, "utf8") case Absent => () tls.privateKeyPath match - case Present(p) => serverOpts.key = js.Dynamic.global.require("fs").readFileSync(p, "utf8") + case Present(p) => serverOpts.key = HttpFs.asInstanceOf[js.Dynamic].readFileSync(p, "utf8") case Absent => () - val server = js.Dynamic.global.require("tls").createServer(serverOpts) + val server = HttpTls.asInstanceOf[js.Dynamic].createServer(serverOpts) // TLS servers emit "secureConnection" after handshake (not "connection" which fires on raw TCP) listenServer(server, host, port, backlog, tcpNoDelay = false, connectionEvent = "secureConnection", handler) end listen @@ -166,7 +166,7 @@ final private[kyo] class JsTransport private ( val promise = new IOPromise[Closed, Connection[JsHandle]] val driver = pool.next() - val net = js.Dynamic.global.require("net") + val net = HttpNet.asInstanceOf[js.Dynamic] val socket = net.createConnection(js.Dynamic.literal(path = path)) // Pause immediately - kyo controls data flow @@ -200,7 +200,7 @@ final private[kyo] class JsTransport private ( )(using AllowUnsafe, Frame): Fiber.Unsafe[Listener, Abort[Closed]] = val promise = new IOPromise[Closed, Listener] - val net = js.Dynamic.global.require("net") + val net = HttpNet.asInstanceOf[js.Dynamic] val server = net.createServer() val listener = new JsListener(server, HttpAddress.Unix(path)) diff --git a/kyo-http/js-wasm/src/main/scala/kyo/internal/NodeBuiltins.scala b/kyo-http/js-wasm/src/main/scala/kyo/internal/NodeBuiltins.scala new file mode 100644 index 0000000000..67bafaaa14 --- /dev/null +++ b/kyo-http/js-wasm/src/main/scala/kyo/internal/NodeBuiltins.scala @@ -0,0 +1,46 @@ +package kyo.internal + +import scala.scalajs.js +import scala.scalajs.js.annotation.* + +/** Facades for the Node.js built-in modules kyo-http uses. + * + * `@JSImport` compiles to `require(...)` under CommonJS and to `import` under ESModule, so the + * same source works on both the JS and the WebAssembly backends. A `js.Dynamic.global.require` + * call would not: `require` is not a global in a Node ES module, which is the module kind the + * experimental WASM backend mandates. The members are typed as `js.Object` and accessed via + * `asInstanceOf[js.Dynamic]` at the call sites, matching the existing dynamic usage. + * + * Names are Http-prefixed to avoid clashing with the similar facades in kyo-core's `kyo.internal`. + * The module ids use the `node:` scheme: under ESModule a namespace import of a bare builtin + * ("path") does not reliably expose its named members (e.g. path.join), while "node:path" does. + */ +@js.native +@JSImport("node:net", JSImport.Namespace) +private[kyo] object HttpNet extends js.Object + +@js.native +@JSImport("node:tls", JSImport.Namespace) +private[kyo] object HttpTls extends js.Object + +@js.native +@JSImport("node:fs", JSImport.Namespace) +private[kyo] object HttpFs extends js.Object + +@js.native +@JSImport("node:os", JSImport.Namespace) +private[kyo] object HttpOs extends js.Object: + def tmpdir(): String = js.native + def homedir(): String = js.native +end HttpOs + +@js.native +@JSImport("node:path", JSImport.Namespace) +private[kyo] object HttpNodePath extends js.Object: + def join(parts: String*): String = js.native + def dirname(path: String): String = js.native +end HttpNodePath + +@js.native +@JSImport("node:child_process", JSImport.Namespace) +private[kyo] object HttpChildProcess extends js.Object diff --git a/kyo-http/js/src/test/scala/kyo/internal/HttpTestPlatformBackend.scala b/kyo-http/js-wasm/src/test/scala/kyo/internal/HttpTestPlatformBackend.scala similarity index 100% rename from kyo-http/js/src/test/scala/kyo/internal/HttpTestPlatformBackend.scala rename to kyo-http/js-wasm/src/test/scala/kyo/internal/HttpTestPlatformBackend.scala diff --git a/kyo-http/js/src/test/scala/kyo/internal/TlsTestHelper.scala b/kyo-http/js-wasm/src/test/scala/kyo/internal/TlsTestHelper.scala similarity index 66% rename from kyo-http/js/src/test/scala/kyo/internal/TlsTestHelper.scala rename to kyo-http/js-wasm/src/test/scala/kyo/internal/TlsTestHelper.scala index b829d2d7d2..3efa1f5e2e 100644 --- a/kyo-http/js/src/test/scala/kyo/internal/TlsTestHelper.scala +++ b/kyo-http/js-wasm/src/test/scala/kyo/internal/TlsTestHelper.scala @@ -9,15 +9,12 @@ import scala.scalajs.js */ object TlsTestHelper: - private val fs = js.Dynamic.global.require("fs") - private val os = js.Dynamic.global.require("os") - private val childProcess = js.Dynamic.global.require("child_process") - private val path = js.Dynamic.global.require("path") + private val childProcess = HttpChildProcess.asInstanceOf[js.Dynamic] lazy val (certPath, keyPath): (String, String) = - val tmpDir = os.tmpdir().asInstanceOf[String] - val certFile = path.join(tmpDir, "kyo-tls-cert.pem").asInstanceOf[String] - val keyFile = path.join(tmpDir, "kyo-tls-key.pem").asInstanceOf[String] + val tmpDir = HttpOs.tmpdir() + val certFile = HttpNodePath.join(tmpDir, "kyo-tls-cert.pem") + val keyFile = HttpNodePath.join(tmpDir, "kyo-tls-key.pem") val cmd = s"""openssl req -x509 -newkey rsa:2048 -keyout "$keyFile" -out "$certFile" -days 365 -nodes -subj "/CN=localhost" 2>&1""" childProcess.execSync(cmd) diff --git a/kyo-http/js/src/test/scala/kyo/internal/UnixSocketTestHelperImpl.scala b/kyo-http/js-wasm/src/test/scala/kyo/internal/UnixSocketTestHelperImpl.scala similarity index 55% rename from kyo-http/js/src/test/scala/kyo/internal/UnixSocketTestHelperImpl.scala rename to kyo-http/js-wasm/src/test/scala/kyo/internal/UnixSocketTestHelperImpl.scala index 9f5acf1652..b553e1ab7c 100644 --- a/kyo-http/js/src/test/scala/kyo/internal/UnixSocketTestHelperImpl.scala +++ b/kyo-http/js-wasm/src/test/scala/kyo/internal/UnixSocketTestHelperImpl.scala @@ -5,20 +5,18 @@ import scala.scalajs.js private[kyo] trait UnixSocketTestHelperImpl extends UnixSocketTestHelper: - private val os = js.Dynamic.global.require("os") - private val path = js.Dynamic.global.require("path") - private val fs = js.Dynamic.global.require("fs") + private val fs = HttpFs.asInstanceOf[js.Dynamic] def tempSocketPath()(using Frame): String < Sync = Sync.defer { - val tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kyo-unix-test-")).toString - path.join(tmpDir, "test.sock").toString + val tmpDir = fs.mkdtempSync(HttpNodePath.join(HttpOs.tmpdir(), "kyo-unix-test-")).toString + HttpNodePath.join(tmpDir, "test.sock") } def cleanupSocket(socketPath: String): Unit = try fs.unlinkSync(socketPath) - val dir = path.dirname(socketPath).toString + val dir = HttpNodePath.dirname(socketPath) discard(fs.rmdirSync(dir)) catch case _: Throwable => () end cleanupSocket diff --git a/kyo-kernel/README.md b/kyo-kernel/README.md index d8f0ef12e3..12d8dbff60 100644 --- a/kyo-kernel/README.md +++ b/kyo-kernel/README.md @@ -737,7 +737,7 @@ val result: List[Greeting] = A few hardcoded behaviors of the runtime occasionally matter when reading stack traces or benchmarking. -The kernel suspends a synchronous chain every 512 frames (`maxStackDepth = 512`). A long-running pure chain that the JIT could in principle inline does not get inlined past this depth; it suspends through `Safepoint` instead. This is what makes Kyo stack-safe without unbounded recursion. Reader expectation: "stack-safe = unbounded recursion at no cost" is wrong; deep synchronous chains still pay the suspension cost periodically. +The kernel suspends a synchronous chain every `maxStackDepth` frames. This threshold is platform-specific (set in `kyo.internal.Platform`): 512 on the JVM and JS, 256 on Native and WebAssembly, which run on smaller call stacks. A long-running pure chain that the JIT could in principle inline does not get inlined past this depth; it suspends through `Safepoint` instead. This is what makes Kyo stack-safe without unbounded recursion. Reader expectation: "stack-safe = unbounded recursion at no cost" is wrong; deep synchronous chains still pay the suspension cost periodically. `Safepoint.enter` checks both stack depth and thread id. A `Kyo` value created on thread A and resumed on thread B forces a re-entry through the suspension machinery. Cross-thread reuse is not a no-op; if you build a pending value on one thread and execute it on another, expect the first step to take the suspension path. diff --git a/kyo-kernel/js/src/main/scala/kyo/kernel/internal/TracePool.scala b/kyo-kernel/js-wasm/src/main/scala/kyo/kernel/internal/TracePool.scala similarity index 100% rename from kyo-kernel/js/src/main/scala/kyo/kernel/internal/TracePool.scala rename to kyo-kernel/js-wasm/src/main/scala/kyo/kernel/internal/TracePool.scala diff --git a/kyo-kernel/shared/src/main/scala/kyo/kernel/internal/package.scala b/kyo-kernel/shared/src/main/scala/kyo/kernel/internal/package.scala index 477ae62af5..516562e6f0 100644 --- a/kyo-kernel/shared/src/main/scala/kyo/kernel/internal/package.scala +++ b/kyo-kernel/shared/src/main/scala/kyo/kernel/internal/package.scala @@ -2,7 +2,9 @@ package kyo.kernel.internal import kyo.kernel.ArrowEffect -private[kernel] inline def maxStackDepth = 512 +// The stack-safety suspension threshold is platform-specific (smaller call stacks on Native +// and WASM need to suspend sooner); each platform sets it in kyo.internal.Platform. +private[kernel] inline def maxStackDepth = kyo.internal.Platform.maxStackDepth private[kernel] inline def maxTraceFrames = 16 private[kernel] type IX[_] diff --git a/kyo-kernel/shared/src/test/scala/kyo/KyoTest.scala b/kyo-kernel/shared/src/test/scala/kyo/KyoTest.scala index 9f0aa33a7f..97fdabbb71 100644 --- a/kyo-kernel/shared/src/test/scala/kyo/KyoTest.scala +++ b/kyo-kernel/shared/src/test/scala/kyo/KyoTest.scala @@ -157,7 +157,7 @@ class KyoTest extends Test: assert(incr(0, n).eval == n) } - "suspension at the start" taggedAs notNative in pendingUntilFixed { + "suspension at the start" taggedAs (notNative, notWasm) in pendingUntilFixed { try assert(TestEffect1.run(incr(TestEffect1(n), n)).eval == 0) catch @@ -170,7 +170,7 @@ class KyoTest extends Test: assert(TestEffect1.run(incr(n, n).map(n => TestEffect1(n + n))).eval == n * 4 + 1) } - "multiple effects" taggedAs notNative in pendingUntilFixed { + "multiple effects" taggedAs (notNative, notWasm) in pendingUntilFixed { @tailrec def incr(v: Int < TestEffect1, n: Int): Int < TestEffect1 = n match case 0 => v diff --git a/kyo-kernel/shared/src/test/scala/kyo/kernel/Tagged.scala b/kyo-kernel/shared/src/test/scala/kyo/kernel/Tagged.scala index c2d1b93b85..9d26c564d6 100644 --- a/kyo-kernel/shared/src/test/scala/kyo/kernel/Tagged.scala +++ b/kyo-kernel/shared/src/test/scala/kyo/kernel/Tagged.scala @@ -6,5 +6,6 @@ object Tagged: private def runWhen(cond: => Boolean) = if cond then "" else "org.scalatest.Ignore" object jvmOnly extends Tag(runWhen(kyo.internal.Platform.isJVM)) object notNative extends Tag(runWhen(!kyo.internal.Platform.isNative)) + object notWasm extends Tag(runWhen(!kyo.internal.Platform.isWasm)) object jsOnly extends Tag(runWhen(kyo.internal.Platform.isJS)) end Tagged diff --git a/kyo-parse/wasm/src/test/scala/kyo/WasmParseTest.scala b/kyo-parse/wasm/src/test/scala/kyo/WasmParseTest.scala new file mode 100644 index 0000000000..15dd6b0730 --- /dev/null +++ b/kyo-parse/wasm/src/test/scala/kyo/WasmParseTest.scala @@ -0,0 +1,5 @@ +package kyo + +// WASM runs on a small call stack like the JS backend, so it uses the same conservative +// parse depth as JSParseTest rather than the 1 << 13 the JVM and Native handle. +class WasmParseTest extends ParseTest(1 << 5) diff --git a/kyo-pod/js/src/test/scala/kyo/ContainerRuntime.scala b/kyo-pod/js-wasm/src/test/scala/kyo/ContainerRuntime.scala similarity index 80% rename from kyo-pod/js/src/test/scala/kyo/ContainerRuntime.scala rename to kyo-pod/js-wasm/src/test/scala/kyo/ContainerRuntime.scala index c5adb0853c..79e418ff59 100644 --- a/kyo-pod/js/src/test/scala/kyo/ContainerRuntime.scala +++ b/kyo-pod/js-wasm/src/test/scala/kyo/ContainerRuntime.scala @@ -4,22 +4,20 @@ import scala.scalajs.js object ContainerRuntime extends ContainerRuntimeBase: - private val childProcess = js.Dynamic.global.require("node:child_process") - /** On Node.js, `user.home` Java system property is not set. Use Node's `os.homedir()` instead. */ override private[kyo] def getHome(using AllowUnsafe): String = - try js.Dynamic.global.require("os").homedir().asInstanceOf[String] + try PodNodeOs.homedir() catch case _: Throwable => "" private[kyo] def cliExists(command: String): Boolean = try - childProcess.execSync(s"$command version", js.Dynamic.literal(stdio = "pipe")) + PodNodeChildProcess.execSync(s"$command version", js.Dynamic.literal(stdio = "pipe")) true catch case _: Throwable => false private[kyo] def queryPodmanMachineSockets: Seq[String] = try - val output = childProcess.execSync( + val output = PodNodeChildProcess.execSync( "podman machine inspect --format json", js.Dynamic.literal(stdio = js.Array("pipe", "pipe", "pipe"), encoding = "utf8") ).asInstanceOf[String] diff --git a/kyo-pod/js-wasm/src/test/scala/kyo/PodNodeBuiltins.scala b/kyo-pod/js-wasm/src/test/scala/kyo/PodNodeBuiltins.scala new file mode 100644 index 0000000000..e84f900d19 --- /dev/null +++ b/kyo-pod/js-wasm/src/test/scala/kyo/PodNodeBuiltins.scala @@ -0,0 +1,24 @@ +package kyo + +import scala.scalajs.js +import scala.scalajs.js.annotation.* + +/** Node built-in facades for kyo-pod's container-runtime test helper. + * + * Declared (typed) members rather than `js.Dynamic.global.require`: `@JSImport` compiles to + * require under CommonJS and to import under ESModule (the kind the WASM backend mandates), + * and a declared member access is what makes the Scala.js linker emit the import. Names are + * Pod-prefixed so they do not clash with the `kyo.*` namespace. + */ +@js.native +@JSImport("node:child_process", JSImport.Namespace) +private[kyo] object PodNodeChildProcess extends js.Object: + def execSync(command: String): js.Dynamic = js.native + def execSync(command: String, options: js.Any): js.Dynamic = js.native +end PodNodeChildProcess + +@js.native +@JSImport("node:os", JSImport.Namespace) +private[kyo] object PodNodeOs extends js.Object: + def homedir(): String = js.native +end PodNodeOs diff --git a/kyo-prelude/shared/src/test/scala/kyo/ChoiceTest.scala b/kyo-prelude/shared/src/test/scala/kyo/ChoiceTest.scala index 123611f0c3..d2dfad2e79 100644 --- a/kyo-prelude/shared/src/test/scala/kyo/ChoiceTest.scala +++ b/kyo-prelude/shared/src/test/scala/kyo/ChoiceTest.scala @@ -84,7 +84,7 @@ class ChoiceTest extends Test: end try } - "large number of suspensions" taggedAs notNative in pendingUntilFixed { + "large number of suspensions" taggedAs (notNative, notWasm) in pendingUntilFixed { // https://github.com/getkyo/kyo/issues/208 var v = Choice.eval(1) for _ <- 0 until 100000 do diff --git a/kyo-prelude/shared/src/test/scala/kyo/Tagged.scala b/kyo-prelude/shared/src/test/scala/kyo/Tagged.scala index c2d1b93b85..9d26c564d6 100644 --- a/kyo-prelude/shared/src/test/scala/kyo/Tagged.scala +++ b/kyo-prelude/shared/src/test/scala/kyo/Tagged.scala @@ -6,5 +6,6 @@ object Tagged: private def runWhen(cond: => Boolean) = if cond then "" else "org.scalatest.Ignore" object jvmOnly extends Tag(runWhen(kyo.internal.Platform.isJVM)) object notNative extends Tag(runWhen(!kyo.internal.Platform.isNative)) + object notWasm extends Tag(runWhen(!kyo.internal.Platform.isWasm)) object jsOnly extends Tag(runWhen(kyo.internal.Platform.isJS)) end Tagged diff --git a/kyo-scheduler/js/src/main/scala/kyo/scheduler/Coordinator.scala b/kyo-scheduler/js-wasm/src/main/scala/kyo/scheduler/Coordinator.scala similarity index 100% rename from kyo-scheduler/js/src/main/scala/kyo/scheduler/Coordinator.scala rename to kyo-scheduler/js-wasm/src/main/scala/kyo/scheduler/Coordinator.scala diff --git a/kyo-scheduler/js/src/main/scala/kyo/scheduler/InternalClock.scala b/kyo-scheduler/js-wasm/src/main/scala/kyo/scheduler/InternalClock.scala similarity index 100% rename from kyo-scheduler/js/src/main/scala/kyo/scheduler/InternalClock.scala rename to kyo-scheduler/js-wasm/src/main/scala/kyo/scheduler/InternalClock.scala diff --git a/kyo-scheduler/js/src/main/scala/kyo/scheduler/Scheduler.scala b/kyo-scheduler/js-wasm/src/main/scala/kyo/scheduler/Scheduler.scala similarity index 100% rename from kyo-scheduler/js/src/main/scala/kyo/scheduler/Scheduler.scala rename to kyo-scheduler/js-wasm/src/main/scala/kyo/scheduler/Scheduler.scala diff --git a/kyo-scheduler/js/src/main/scala/kyo/scheduler/util/Threads.scala b/kyo-scheduler/js-wasm/src/main/scala/kyo/scheduler/util/Threads.scala similarity index 100% rename from kyo-scheduler/js/src/main/scala/kyo/scheduler/util/Threads.scala rename to kyo-scheduler/js-wasm/src/main/scala/kyo/scheduler/util/Threads.scala diff --git a/kyo-schema/js/src/main/scala/kyo/internal/AsciiStringFactory.scala b/kyo-schema/js-wasm/src/main/scala/kyo/internal/AsciiStringFactory.scala similarity index 100% rename from kyo-schema/js/src/main/scala/kyo/internal/AsciiStringFactory.scala rename to kyo-schema/js-wasm/src/main/scala/kyo/internal/AsciiStringFactory.scala diff --git a/kyo-stats-otlp/js/src/main/scala/kyo/stats/otlp/OTLPRegistration.scala b/kyo-stats-otlp/js-wasm/src/main/scala/kyo/stats/otlp/OTLPRegistration.scala similarity index 100% rename from kyo-stats-otlp/js/src/main/scala/kyo/stats/otlp/OTLPRegistration.scala rename to kyo-stats-otlp/js-wasm/src/main/scala/kyo/stats/otlp/OTLPRegistration.scala diff --git a/kyo-stats-registry/js-native/src/main/scala/java/lang/ref/WeakReference.scala b/kyo-stats-registry/js-native-wasm/src/main/scala/java/lang/ref/WeakReference.scala similarity index 100% rename from kyo-stats-registry/js-native/src/main/scala/java/lang/ref/WeakReference.scala rename to kyo-stats-registry/js-native-wasm/src/main/scala/java/lang/ref/WeakReference.scala diff --git a/kyo-ui/js/src/main/scala/kyo/UILocation.scala b/kyo-ui/js-wasm/src/main/scala/kyo/UILocation.scala similarity index 100% rename from kyo-ui/js/src/main/scala/kyo/UILocation.scala rename to kyo-ui/js-wasm/src/main/scala/kyo/UILocation.scala diff --git a/kyo-ui/js/src/main/scala/kyo/UIMount.scala b/kyo-ui/js-wasm/src/main/scala/kyo/UIMount.scala similarity index 100% rename from kyo-ui/js/src/main/scala/kyo/UIMount.scala rename to kyo-ui/js-wasm/src/main/scala/kyo/UIMount.scala diff --git a/kyo-ui/js/src/main/scala/kyo/UIWindow.scala b/kyo-ui/js-wasm/src/main/scala/kyo/UIWindow.scala similarity index 100% rename from kyo-ui/js/src/main/scala/kyo/UIWindow.scala rename to kyo-ui/js-wasm/src/main/scala/kyo/UIWindow.scala diff --git a/kyo-ui/js/src/main/scala/kyo/internal/DomBackend.scala b/kyo-ui/js-wasm/src/main/scala/kyo/internal/DomBackend.scala similarity index 100% rename from kyo-ui/js/src/main/scala/kyo/internal/DomBackend.scala rename to kyo-ui/js-wasm/src/main/scala/kyo/internal/DomBackend.scala diff --git a/kyo-ui/js/src/main/scala/kyo/internal/DomStyleSheet.scala b/kyo-ui/js-wasm/src/main/scala/kyo/internal/DomStyleSheet.scala similarity index 100% rename from kyo-ui/js/src/main/scala/kyo/internal/DomStyleSheet.scala rename to kyo-ui/js-wasm/src/main/scala/kyo/internal/DomStyleSheet.scala diff --git a/kyo-zio/js/src/test/scala/java/util/concurrent/CountDownLatch.scala b/kyo-zio/js-wasm/src/test/scala/java/util/concurrent/CountDownLatch.scala similarity index 67% rename from kyo-zio/js/src/test/scala/java/util/concurrent/CountDownLatch.scala rename to kyo-zio/js-wasm/src/test/scala/java/util/concurrent/CountDownLatch.scala index 43faa761ef..bb55da0e21 100644 --- a/kyo-zio/js/src/test/scala/java/util/concurrent/CountDownLatch.scala +++ b/kyo-zio/js-wasm/src/test/scala/java/util/concurrent/CountDownLatch.scala @@ -2,4 +2,4 @@ package java.util.concurrent class CountDownLatch(count: Int): def await(time: Long, unit: TimeUnit): Boolean = ??? - def countDown() = ??? + def countDown(): Unit = ??? diff --git a/project/TestKyo.scala b/project/TestKyo.scala index e274824c76..5831bddc22 100644 --- a/project/TestKyo.scala +++ b/project/TestKyo.scala @@ -12,7 +12,7 @@ import scala.sys.process.* */ object TestKyo { - private val platformNames = Set("JVM", "JS", "Native") + private val platformNames = Set("JVM", "JS", "Native", "Wasm") private def log(msg: String): Unit = println(s"[testKyo] $msg") @@ -85,7 +85,7 @@ object TestKyo { val allRefs = structure.allProjectRefs // Exclude aggregate projects - val excluded = Set("kyoJVM", "kyoJS", "kyoNative") + val excluded = Set("kyoJVM", "kyoJS", "kyoNative", "kyoWasm") val testable = allRefs.filter { ref => val name = ref.project val versions = (ref / crossScalaVersions).get(structure.data).getOrElse(Nil) @@ -184,9 +184,10 @@ object TestKyo { */ private def matchesPlatform(name: String, platform: String): Boolean = platform match { - case "JVM" => !name.endsWith("JS") && !name.endsWith("Native") + case "JVM" => !name.endsWith("JS") && !name.endsWith("Native") && !name.endsWith("Wasm") case "JS" => name.endsWith("JS") case "Native" => name.endsWith("Native") + case "Wasm" => name.endsWith("Wasm") case _ => false } @@ -199,12 +200,16 @@ object TestKyo { val parts = file.split("/").toList parts match { case module :: sub :: _ => + // Map the platform sub-directory to affected platforms. Handles single + // platform dirs (jvm/js/native/wasm), the partially-shared dirs named by + // joining identifiers (e.g. js-wasm, jvm-native), shared (all platforms), + // and any other layout (all, then filtered by which projects exist). + val platformDirs = Map("jvm" -> "JVM", "js" -> "JS", "native" -> "Native", "wasm" -> "Wasm") + val allPlatforms = platformDirs.values.toSeq val affectedPlatforms = sub match { - case "shared" => Seq("JVM", "JS", "Native") - case "jvm" => Seq("JVM") - case "js" => Seq("JS") - case "native" => Seq("Native") - case _ => Seq("JVM", "JS", "Native") + case "shared" => allPlatforms + case s if s.split("-").forall(platformDirs.contains) => s.split("-").toList.map(platformDirs) + case _ => allPlatforms } affectedPlatforms.flatMap { p => // JVM projects may use bare name (e.g. "kyo-core") or suffixed (e.g. "kyo-coreJVM") diff --git a/project/WasmPlatform.scala b/project/WasmPlatform.scala new file mode 100644 index 0000000000..324143fa22 --- /dev/null +++ b/project/WasmPlatform.scala @@ -0,0 +1,45 @@ +import org.scalajs.linker.interface.ModuleKind +import org.scalajs.sbtplugin.ScalaJSPlugin +import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.scalaJSLinkerConfig +import sbt.* +import sbtcrossproject.* +import scala.language.implicitConversions + +/** Cross-project platform for the experimental Scala.js WebAssembly backend. + * + * WebAssembly is not a separate compilation target in the portable-scala model: it is an output mode of the Scala.js linker. So this + * platform enables the same ScalaJSPlugin as [[scalajscrossproject.JSPlatform]] and only differs by flipping the linker into WebAssembly + * mode. The backend requires `ESModule` output and the WasmGC + exception-handling runtime features. + * + * Sources resolve as `shared/` + `wasm/`, and the `js-wasm/` partially-shared directory (auto-wired by `CrossType.Full`) holds Scala.js + * code common to the `js` and `wasm` platforms. Keeping the linker config here means no module has to repeat it. + */ +case object WasmPlatform extends Platform { + def identifier: String = "wasm" + def sbtSuffix: String = "Wasm" + def enable(project: Project): Project = + project.enablePlugins(ScalaJSPlugin).settings( + scalaJSLinkerConfig ~= { + _.withExperimentalUseWebAssembly(true) + .withModuleKind(ModuleKind.ESModule) + } + ) +} + +/** Cross-project operations for [[WasmPlatform]], mirroring `scalajscrossproject.ScalaJSCrossPlugin`'s `.js` helpers. Import + * `WasmCrossProject.*` in build.sbt to use `.wasm`, `.wasmSettings`, `.wasmConfigure`, and `.wasmEnablePlugins`. + */ +object WasmCrossProject { + implicit class WasmCrossProjectOps(private val project: CrossProject) extends AnyVal { + def wasm: Project = project.projects(WasmPlatform) + + def wasmSettings(ss: Def.SettingsDefinition*): CrossProject = + wasmConfigure(_.settings(ss*)) + + def wasmEnablePlugins(plugins: Plugins*): CrossProject = + wasmConfigure(_.enablePlugins(plugins*)) + + def wasmConfigure(transformer: Project => Project): CrossProject = + project.configurePlatform(WasmPlatform)(transformer) + } +} From 2dd2254ad0072022ce316807ddd3c81f1aaf6167 Mon Sep 17 00:00:00 2001 From: Flavio Brasil Date: Sat, 6 Jun 2026 00:15:06 -0300 Subject: [PATCH 2/3] kyo-test: cross-build for wasm so #1663's Wasm CI target is green #1658 replaced ScalaTest with kyo-test but only cross-built it for JVM/JS/Native. #1663 runs tests on a 4th target (Wasm), so the kyo-test modules and the WithKyoTest wiring need a wasm variant. - build.sbt: add WasmPlatform + .wasmSettings to kyo-test-api/runner/prop/ snapshot (snapshot keeps WasmPlatform's ESModule for the @JSImport node:fs facade); aggregate them into kyoWasm - WithKyoTest: add a wasm branch wiring kyo-test-runnerWasm and reusing JsFramework (wasm is a Scala.js linker backend with the same sbt test bridge) - move the 15 kyo-test js sources into js-wasm/ (shared js+wasm), mirroring how kyo-core shares Scala.js code across js and wasm Validated: kyoWasm/Test/compile green across all modules; kyo-kernel + kyo-prelude + kyo-core run green on wasm (2000+ tests, 0 failed) with the .notNative.notWasm leaves correctly compile-excluded; JS unchanged. --- build.sbt | 61 +++++++++++++++++-- .../test/internal/KyoTestReflectPort.scala | 0 .../kyo/test/internal/ApiTestSleep.scala | 0 .../kyo/test/prop/TestExecutionContext.scala | 0 .../scala/kyo/test/runner/JsFramework.scala | 0 .../test/runner/internal/CliPlatform.scala | 0 .../runner/internal/InstantiatePlatform.scala | 0 .../kyo/test/runner/internal/JsRunner.scala | 0 .../kyo/test/runner/internal/JsTask.scala | 0 .../runner/internal/ReportersPlatform.scala | 0 .../internal/SuiteDiscoveryPlatform.scala | 0 .../kyo/test/runner/ArgsJunitXmlTest.scala | 0 .../kyo/test/runner/JsFrameworkTest.scala | 0 .../test/runner/TestExecutionContext.scala | 0 .../scala/kyo/test/runner/TestSleep.scala | 0 .../internal/SnapshotStorePlatform.scala | 0 project/WithKyoTest.scala | 21 +++++-- 17 files changed, 72 insertions(+), 10 deletions(-) rename kyo-test/api/{js => js-wasm}/src/main/scala/kyo/test/internal/KyoTestReflectPort.scala (100%) rename kyo-test/api/{js => js-wasm}/src/test/scala/kyo/test/internal/ApiTestSleep.scala (100%) rename kyo-test/prop/{js => js-wasm}/src/test/scala/kyo/test/prop/TestExecutionContext.scala (100%) rename kyo-test/runner/{js => js-wasm}/src/main/scala/kyo/test/runner/JsFramework.scala (100%) rename kyo-test/runner/{js => js-wasm}/src/main/scala/kyo/test/runner/internal/CliPlatform.scala (100%) rename kyo-test/runner/{js => js-wasm}/src/main/scala/kyo/test/runner/internal/InstantiatePlatform.scala (100%) rename kyo-test/runner/{js => js-wasm}/src/main/scala/kyo/test/runner/internal/JsRunner.scala (100%) rename kyo-test/runner/{js => js-wasm}/src/main/scala/kyo/test/runner/internal/JsTask.scala (100%) rename kyo-test/runner/{js => js-wasm}/src/main/scala/kyo/test/runner/internal/ReportersPlatform.scala (100%) rename kyo-test/runner/{js => js-wasm}/src/main/scala/kyo/test/runner/internal/SuiteDiscoveryPlatform.scala (100%) rename kyo-test/runner/{js => js-wasm}/src/test/scala/kyo/test/runner/ArgsJunitXmlTest.scala (100%) rename kyo-test/runner/{js => js-wasm}/src/test/scala/kyo/test/runner/JsFrameworkTest.scala (100%) rename kyo-test/runner/{js => js-wasm}/src/test/scala/kyo/test/runner/TestExecutionContext.scala (100%) rename kyo-test/runner/{js => js-wasm}/src/test/scala/kyo/test/runner/TestSleep.scala (100%) rename kyo-test/snapshot/{js => js-wasm}/src/main/scala/kyo/test/snapshot/internal/SnapshotStorePlatform.scala (100%) diff --git a/build.sbt b/build.sbt index 65f3f3c320..1d7d54731a 100644 --- a/build.sbt +++ b/build.sbt @@ -378,7 +378,11 @@ lazy val kyoWasm = project `kyo-flow`.wasm, `kyo-pod`.wasm, `kyo-browser`.wasm, - `kyo-ui`.wasm + `kyo-ui`.wasm, + `kyo-test-api`.wasm, + `kyo-test-runner`.wasm, + `kyo-test-prop`.wasm, + `kyo-test-snapshot`.wasm ) lazy val `kyo-scheduler` = @@ -1691,7 +1695,7 @@ lazy val `kyo-compat-plugin` = (project in file("kyo-compat/plugin")) // =========================================================================== lazy val `kyo-test-api` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .dependsOn(`kyo-data`) @@ -1733,9 +1737,20 @@ lazy val `kyo-test-api` = Test / unmanagedClasspath ++= (LocalProject("kyo-coreJS") / Compile / fullClasspath).value ) + .wasmSettings( + `wasm-settings`, + Compile / unmanagedClasspath ++= + (LocalProject("kyo-preludeWasm") / Compile / fullClasspath).value, + Compile / unmanagedClasspath ++= + (LocalProject("kyo-coreWasm") / Compile / fullClasspath).value, + Test / unmanagedClasspath ++= + (LocalProject("kyo-preludeWasm") / Compile / fullClasspath).value, + Test / unmanagedClasspath ++= + (LocalProject("kyo-coreWasm") / Compile / fullClasspath).value + ) lazy val `kyo-test-runner` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .dependsOn(`kyo-test-api`) @@ -1783,9 +1798,21 @@ lazy val `kyo-test-runner` = Test / unmanagedClasspath ++= (LocalProject("kyo-coreJS") / Compile / fullClasspath).value ) + .wasmSettings( + `wasm-settings`, + libraryDependencies += "org.scala-sbt" % "test-interface" % "1.0" % Provided, + Compile / unmanagedClasspath ++= + (LocalProject("kyo-preludeWasm") / Compile / fullClasspath).value, + Compile / unmanagedClasspath ++= + (LocalProject("kyo-coreWasm") / Compile / fullClasspath).value, + Test / unmanagedClasspath ++= + (LocalProject("kyo-preludeWasm") / Compile / fullClasspath).value, + Test / unmanagedClasspath ++= + (LocalProject("kyo-coreWasm") / Compile / fullClasspath).value + ) lazy val `kyo-test-prop` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .dependsOn(`kyo-test-api`) @@ -1829,9 +1856,20 @@ lazy val `kyo-test-prop` = Test / unmanagedClasspath ++= (LocalProject("kyo-coreJS") / Compile / fullClasspath).value ) + .wasmSettings( + `wasm-settings`, + Compile / unmanagedClasspath ++= + (LocalProject("kyo-preludeWasm") / Compile / fullClasspath).value, + Compile / unmanagedClasspath ++= + (LocalProject("kyo-coreWasm") / Compile / fullClasspath).value, + Test / unmanagedClasspath ++= + (LocalProject("kyo-preludeWasm") / Compile / fullClasspath).value, + Test / unmanagedClasspath ++= + (LocalProject("kyo-coreWasm") / Compile / fullClasspath).value + ) lazy val `kyo-test-snapshot` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .dependsOn(`kyo-test-api`) @@ -1884,6 +1922,19 @@ lazy val `kyo-test-snapshot` = (LocalProject("kyo-coreJS") / Compile / fullClasspath).value, scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) } ) + // WASM keeps WasmPlatform's ESModule linker kind (no CommonJSModule override): the + // @JSImport("node:fs") snapshot facade resolves as an ESM import under Node. + .wasmSettings( + `wasm-settings`, + Compile / unmanagedClasspath ++= + (LocalProject("kyo-preludeWasm") / Compile / fullClasspath).value, + Compile / unmanagedClasspath ++= + (LocalProject("kyo-coreWasm") / Compile / fullClasspath).value, + Test / unmanagedClasspath ++= + (LocalProject("kyo-preludeWasm") / Compile / fullClasspath).value, + Test / unmanagedClasspath ++= + (LocalProject("kyo-coreWasm") / Compile / fullClasspath).value + ) lazy val `kyo-test-sbt` = project diff --git a/kyo-test/api/js/src/main/scala/kyo/test/internal/KyoTestReflectPort.scala b/kyo-test/api/js-wasm/src/main/scala/kyo/test/internal/KyoTestReflectPort.scala similarity index 100% rename from kyo-test/api/js/src/main/scala/kyo/test/internal/KyoTestReflectPort.scala rename to kyo-test/api/js-wasm/src/main/scala/kyo/test/internal/KyoTestReflectPort.scala diff --git a/kyo-test/api/js/src/test/scala/kyo/test/internal/ApiTestSleep.scala b/kyo-test/api/js-wasm/src/test/scala/kyo/test/internal/ApiTestSleep.scala similarity index 100% rename from kyo-test/api/js/src/test/scala/kyo/test/internal/ApiTestSleep.scala rename to kyo-test/api/js-wasm/src/test/scala/kyo/test/internal/ApiTestSleep.scala diff --git a/kyo-test/prop/js/src/test/scala/kyo/test/prop/TestExecutionContext.scala b/kyo-test/prop/js-wasm/src/test/scala/kyo/test/prop/TestExecutionContext.scala similarity index 100% rename from kyo-test/prop/js/src/test/scala/kyo/test/prop/TestExecutionContext.scala rename to kyo-test/prop/js-wasm/src/test/scala/kyo/test/prop/TestExecutionContext.scala diff --git a/kyo-test/runner/js/src/main/scala/kyo/test/runner/JsFramework.scala b/kyo-test/runner/js-wasm/src/main/scala/kyo/test/runner/JsFramework.scala similarity index 100% rename from kyo-test/runner/js/src/main/scala/kyo/test/runner/JsFramework.scala rename to kyo-test/runner/js-wasm/src/main/scala/kyo/test/runner/JsFramework.scala diff --git a/kyo-test/runner/js/src/main/scala/kyo/test/runner/internal/CliPlatform.scala b/kyo-test/runner/js-wasm/src/main/scala/kyo/test/runner/internal/CliPlatform.scala similarity index 100% rename from kyo-test/runner/js/src/main/scala/kyo/test/runner/internal/CliPlatform.scala rename to kyo-test/runner/js-wasm/src/main/scala/kyo/test/runner/internal/CliPlatform.scala diff --git a/kyo-test/runner/js/src/main/scala/kyo/test/runner/internal/InstantiatePlatform.scala b/kyo-test/runner/js-wasm/src/main/scala/kyo/test/runner/internal/InstantiatePlatform.scala similarity index 100% rename from kyo-test/runner/js/src/main/scala/kyo/test/runner/internal/InstantiatePlatform.scala rename to kyo-test/runner/js-wasm/src/main/scala/kyo/test/runner/internal/InstantiatePlatform.scala diff --git a/kyo-test/runner/js/src/main/scala/kyo/test/runner/internal/JsRunner.scala b/kyo-test/runner/js-wasm/src/main/scala/kyo/test/runner/internal/JsRunner.scala similarity index 100% rename from kyo-test/runner/js/src/main/scala/kyo/test/runner/internal/JsRunner.scala rename to kyo-test/runner/js-wasm/src/main/scala/kyo/test/runner/internal/JsRunner.scala diff --git a/kyo-test/runner/js/src/main/scala/kyo/test/runner/internal/JsTask.scala b/kyo-test/runner/js-wasm/src/main/scala/kyo/test/runner/internal/JsTask.scala similarity index 100% rename from kyo-test/runner/js/src/main/scala/kyo/test/runner/internal/JsTask.scala rename to kyo-test/runner/js-wasm/src/main/scala/kyo/test/runner/internal/JsTask.scala diff --git a/kyo-test/runner/js/src/main/scala/kyo/test/runner/internal/ReportersPlatform.scala b/kyo-test/runner/js-wasm/src/main/scala/kyo/test/runner/internal/ReportersPlatform.scala similarity index 100% rename from kyo-test/runner/js/src/main/scala/kyo/test/runner/internal/ReportersPlatform.scala rename to kyo-test/runner/js-wasm/src/main/scala/kyo/test/runner/internal/ReportersPlatform.scala diff --git a/kyo-test/runner/js/src/main/scala/kyo/test/runner/internal/SuiteDiscoveryPlatform.scala b/kyo-test/runner/js-wasm/src/main/scala/kyo/test/runner/internal/SuiteDiscoveryPlatform.scala similarity index 100% rename from kyo-test/runner/js/src/main/scala/kyo/test/runner/internal/SuiteDiscoveryPlatform.scala rename to kyo-test/runner/js-wasm/src/main/scala/kyo/test/runner/internal/SuiteDiscoveryPlatform.scala diff --git a/kyo-test/runner/js/src/test/scala/kyo/test/runner/ArgsJunitXmlTest.scala b/kyo-test/runner/js-wasm/src/test/scala/kyo/test/runner/ArgsJunitXmlTest.scala similarity index 100% rename from kyo-test/runner/js/src/test/scala/kyo/test/runner/ArgsJunitXmlTest.scala rename to kyo-test/runner/js-wasm/src/test/scala/kyo/test/runner/ArgsJunitXmlTest.scala diff --git a/kyo-test/runner/js/src/test/scala/kyo/test/runner/JsFrameworkTest.scala b/kyo-test/runner/js-wasm/src/test/scala/kyo/test/runner/JsFrameworkTest.scala similarity index 100% rename from kyo-test/runner/js/src/test/scala/kyo/test/runner/JsFrameworkTest.scala rename to kyo-test/runner/js-wasm/src/test/scala/kyo/test/runner/JsFrameworkTest.scala diff --git a/kyo-test/runner/js/src/test/scala/kyo/test/runner/TestExecutionContext.scala b/kyo-test/runner/js-wasm/src/test/scala/kyo/test/runner/TestExecutionContext.scala similarity index 100% rename from kyo-test/runner/js/src/test/scala/kyo/test/runner/TestExecutionContext.scala rename to kyo-test/runner/js-wasm/src/test/scala/kyo/test/runner/TestExecutionContext.scala diff --git a/kyo-test/runner/js/src/test/scala/kyo/test/runner/TestSleep.scala b/kyo-test/runner/js-wasm/src/test/scala/kyo/test/runner/TestSleep.scala similarity index 100% rename from kyo-test/runner/js/src/test/scala/kyo/test/runner/TestSleep.scala rename to kyo-test/runner/js-wasm/src/test/scala/kyo/test/runner/TestSleep.scala diff --git a/kyo-test/snapshot/js/src/main/scala/kyo/test/snapshot/internal/SnapshotStorePlatform.scala b/kyo-test/snapshot/js-wasm/src/main/scala/kyo/test/snapshot/internal/SnapshotStorePlatform.scala similarity index 100% rename from kyo-test/snapshot/js/src/main/scala/kyo/test/snapshot/internal/SnapshotStorePlatform.scala rename to kyo-test/snapshot/js-wasm/src/main/scala/kyo/test/snapshot/internal/SnapshotStorePlatform.scala diff --git a/project/WithKyoTest.scala b/project/WithKyoTest.scala index a514a88102..43d52bb98f 100644 --- a/project/WithKyoTest.scala +++ b/project/WithKyoTest.scala @@ -5,6 +5,7 @@ import sbtcrossproject.CrossProject import sbtcrossproject.Platform import scalajscrossproject.ScalaJSCrossPlugin.autoImport.* import scalanativecrossproject.ScalaNativeCrossPlugin.autoImport.* +import WasmCrossProject.* /** Wires kyo-test into a CrossProject using task-level LocalProject references. * @@ -51,14 +52,24 @@ object WithKyoTest { new TestFramework("kyo.test.runner.JsFramework") ) else base - if (cp.projects.contains(NativePlatform)) - withJs.nativeSettings( + val withNative = + if (cp.projects.contains(NativePlatform)) + withJs.nativeSettings( + Test / unmanagedClasspath ++= + (LocalProject("kyo-test-runnerNative") / Test / fullClasspath).value, + Test / testFrameworks += + new TestFramework("kyo.test.runner.NativeFramework") + ) + else withJs + // WASM is a Scala.js linker backend, so it reuses the Scala.js test Framework (JsFramework). + if (cp.projects.contains(WasmPlatform)) + withNative.wasmSettings( Test / unmanagedClasspath ++= - (LocalProject("kyo-test-runnerNative") / Test / fullClasspath).value, + (LocalProject("kyo-test-runnerWasm") / Test / fullClasspath).value, Test / testFrameworks += - new TestFramework("kyo.test.runner.NativeFramework") + new TestFramework("kyo.test.runner.JsFramework") ) - else withJs + else withNative } } } From 7269e11c17fd9e08a1ebda50eb3a65e34ee7eb0f Mon Sep 17 00:00:00 2001 From: Flavio Brasil Date: Sat, 6 Jun 2026 08:35:00 -0300 Subject: [PATCH 3/3] scalafmt: sort imports in project/WithKyoTest.scala (scalafmtSbt) --- project/WithKyoTest.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/WithKyoTest.scala b/project/WithKyoTest.scala index 43d52bb98f..ec435a7a73 100644 --- a/project/WithKyoTest.scala +++ b/project/WithKyoTest.scala @@ -1,3 +1,4 @@ +import WasmCrossProject.* import sbt.* import sbt.Keys.* import sbtcrossproject.CrossPlugin.autoImport.* @@ -5,7 +6,6 @@ import sbtcrossproject.CrossProject import sbtcrossproject.Platform import scalajscrossproject.ScalaJSCrossPlugin.autoImport.* import scalanativecrossproject.ScalaNativeCrossPlugin.autoImport.* -import WasmCrossProject.* /** Wires kyo-test into a CrossProject using task-level LocalProject references. *