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 31ac682983..16f6d87071 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. # @@ -37,7 +37,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: >- @@ -54,10 +54,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 4f1f4dbd2c..a29b6993dc 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,63 +245,63 @@ 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 | ### Testing @@ -298,48 +315,48 @@ The project's own cross-platform test framework. (To run `zio-test` suites with 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 @@ -352,7 +369,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 f5ae6a9c12..1d7d54731a 100644 --- a/build.sbt +++ b/build.sbt @@ -1,3 +1,4 @@ +import WasmCrossProject.* import WithKyoTest._ import com.github.sbt.git.SbtGit.GitKeys.useConsoleForROGit import org.scalajs.jsenv.nodejs.* @@ -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) } @@ -342,8 +344,49 @@ lazy val kyoNative = project `kyo-test-snapshot`.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, + `kyo-test-api`.wasm, + `kyo-test-runner`.wasm, + `kyo-test-prop`.wasm, + `kyo-test-snapshot`.wasm + ) + lazy val `kyo-scheduler` = - crossProject(JSPlatform, JVMPlatform, NativePlatform) + crossProject(JSPlatform, JVMPlatform, NativePlatform, WasmPlatform) .withoutSuffixFor(JVMPlatform) .crossType(CrossType.Full) .dependsOn(`kyo-stats-registry`) @@ -364,6 +407,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) @@ -452,7 +501,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`) @@ -466,9 +515,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`) @@ -485,9 +535,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`) @@ -501,9 +552,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`) @@ -513,9 +565,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") @@ -525,9 +578,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`) @@ -544,6 +598,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) @@ -563,7 +622,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")) @@ -583,9 +642,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")) @@ -595,9 +655,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")) @@ -607,6 +668,7 @@ lazy val `kyo-actor` = .jvmSettings(mimaCheck(false)) .nativeSettings(`native-settings`) .jsSettings(`js-settings`) + .wasmSettings(`wasm-settings`) lazy val `kyo-logging-jpl` = crossProject(JVMPlatform) @@ -633,7 +695,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`) @@ -647,9 +709,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")) @@ -662,9 +725,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")) @@ -679,9 +743,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")) @@ -700,6 +765,7 @@ lazy val `kyo-reactive-streams` = ) .nativeSettings(`native-settings`) .jsSettings(`js-settings`) + .wasmSettings(`wasm-settings`) lazy val `kyo-aeron` = crossProject(JVMPlatform) @@ -726,7 +792,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")) @@ -746,9 +812,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")) @@ -762,6 +829,7 @@ lazy val `kyo-flow` = `js-settings`, scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) } ) + .wasmSettings(`wasm-settings`) lazy val `kyo-caliban` = crossProject(JVMPlatform) @@ -781,7 +849,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")) @@ -800,9 +868,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")) @@ -820,7 +889,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) @@ -838,7 +909,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")) @@ -873,9 +944,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")) @@ -905,9 +977,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")) @@ -939,7 +1012,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) @@ -1057,7 +1132,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")) @@ -1067,9 +1142,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")) @@ -1082,9 +1158,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")) @@ -1184,9 +1261,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")) @@ -1240,9 +1318,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")) @@ -1293,6 +1372,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) @@ -1497,6 +1580,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)) @@ -1592,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`) @@ -1634,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`) @@ -1684,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`) @@ -1730,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`) @@ -1785,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-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 6291270e91..ccf12a629f 100644 --- a/kyo-kernel/shared/src/test/scala/kyo/KyoTest.scala +++ b/kyo-kernel/shared/src/test/scala/kyo/KyoTest.scala @@ -156,7 +156,9 @@ class KyoTest extends kyo.test.Test[Any]: assert(incr(0, n).eval == n) } - "suspension at the start".notNative.pendingUntilFixed("deep effect suspension is not yet stack-safe (StackOverflowError)") in { + "suspension at the start".notNative.notWasm.pendingUntilFixed( + "deep effect suspension is not yet stack-safe (StackOverflowError)" + ) in { try assert(TestEffect1.run(incr(TestEffect1(n), n)).eval == 0) catch @@ -169,7 +171,7 @@ class KyoTest extends kyo.test.Test[Any]: assert(TestEffect1.run(incr(n, n).map(n => TestEffect1(n + n))).eval == n * 4 + 1) } - "multiple effects".notNative.pendingUntilFixed("deep effect suspension is not yet stack-safe (StackOverflowError)") in { + "multiple effects".notNative.notWasm.pendingUntilFixed("deep effect suspension is not yet stack-safe (StackOverflowError)") in { @tailrec def incr(v: Int < TestEffect1, n: Int): Int < TestEffect1 = n match case 0 => v 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 359ca03740..8a10757d46 100644 --- a/kyo-prelude/shared/src/test/scala/kyo/ChoiceTest.scala +++ b/kyo-prelude/shared/src/test/scala/kyo/ChoiceTest.scala @@ -83,7 +83,7 @@ class ChoiceTest extends kyo.test.Test[Any]: end try } - "large number of suspensions".notNative.pendingUntilFixed("deep Choice suspension is not yet stack-safe (issue #208)") in { + "large number of suspensions".notNative.notWasm.pendingUntilFixed("deep Choice suspension is not yet stack-safe (issue #208)") in { // https://github.com/getkyo/kyo/issues/208 var v = Choice.eval(1) for _ <- 0 until 100000 do 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-test/README.md b/kyo-test/README.md index 897e11ff60..e0f61f347f 100644 --- a/kyo-test/README.md +++ b/kyo-test/README.md @@ -271,9 +271,9 @@ end ResilienceTest When a `Passed` or `Failed` result reports `attempts > 1`, a retry decorator rescued (or exhausted on) that leaf; `attempts == 1` means it settled on the first try. -### Platform: `jvm`/`js`/`native`, `notX`, `onlyX` +### Platform: `jvm`/`js`/`native`/`wasm`, `notX`, `onlyX` -Three families select platforms. `.jvm` / `.js` / `.native` add a platform to the include set (additive). `.notJvm` / `.notJs` / `.notNative` remove one. `.onlyJvm` / `.onlyJs` / `.onlyNative` restrict to exactly one. On a non-matching platform the leaf is compile-excluded: the body is never emitted, no code for the leaf reaches the platform's output, and the leaf is absent entirely. It produces no `TestResult` and is never reported. +Three families select platforms. `.jvm` / `.js` / `.native` / `.wasm` add a platform to the include set (additive). `.notJvm` / `.notJs` / `.notNative` / `.notWasm` remove one. `.onlyJvm` / `.onlyJs` / `.onlyNative` / `.onlyWasm` restrict to exactly one. On a non-matching platform the leaf is compile-excluded: the body is never emitted, no code for the leaf reaches the platform's output, and the leaf is absent entirely. It produces no `TestResult` and is never reported. Filters compose: a second filter intersects with the first, so `.notNative.notWasm` excludes the leaf on both Native and WebAssembly while leaving it on the JVM and Scala.js. ```scala class PlatformTest extends Test[Any]: @@ -283,6 +283,9 @@ class PlatformTest extends Test[Any]: "not on Scala Native".notNative in { assert(true) } + "stack-unsafe on AOT targets".notNative.notWasm in { + assert(true) + } end PlatformTest ``` 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/api/shared/src/main/scala/kyo/test/TestBuilder.scala b/kyo-test/api/shared/src/main/scala/kyo/test/TestBuilder.scala index 3e6ab8c9cb..fd885c4d14 100644 --- a/kyo-test/api/shared/src/main/scala/kyo/test/TestBuilder.scala +++ b/kyo-test/api/shared/src/main/scala/kyo/test/TestBuilder.scala @@ -99,26 +99,45 @@ object PlatformSet: /** Enabled on every platform except Scala Native. */ sealed trait NotNative + + /** Enabled only on WebAssembly (Scala.js-wasm). */ + sealed trait OnlyWasm + + /** Enabled on every platform except WebAssembly. */ + sealed trait NotWasm + + /** Enabled where BOTH `A` and `B` are enabled: the marker produced by chaining two platform filters (`.notNative.notWasm`). A distinct + * nominal type (not a structural intersection) so [[gateOf]] can match it without spuriously decomposing a single marker. [[gateOf]] + * reduces it to `gateOf[A] && gateOf[B]`. + */ + sealed trait Both[A, B] end PlatformSet /** Reduce a [[PlatformSet]] marker `P` to the compile-time-constant Boolean saying whether the current platform is in `P`'s enabled set. * * `transparent inline` plus the `inline erasedValue[P] match` makes the result a literal `true`/`false` at each platform compile, because - * `kyo.internal.Platform.isJVM`/`isJS`/`isNative` are themselves inline constants per platform. That constant is what makes + * `kyo.internal.Platform.isJVM`/`isJS`/`isNative`/`isWasm` are themselves inline constants per platform. That constant is what makes * `inline if gateOf[P]` a legal compile-time branch whose dead arm is never emitted. + * + * Platform filters compose: chaining (`.notNative.notWasm`) wraps the markers in [[PlatformSet.Both]], so `P` becomes + * `Both[NotNative, NotWasm]`. The `Both` case reduces `gateOf[Both[a, b]]` to `gateOf[a] && gateOf[b]`, both inline constants, so the leaf + * is enabled only where every marker in the chain is. */ transparent inline def gateOf[P]: Boolean = inline erasedValue[P] match + case _: PlatformSet.Both[a, b] => gateOf[a] && gateOf[b] case _: PlatformSet.All => true case _: PlatformSet.OnlyJvm => kyo.internal.Platform.isJVM case _: PlatformSet.OnlyJs => kyo.internal.Platform.isJS case _: PlatformSet.OnlyNative => kyo.internal.Platform.isNative + case _: PlatformSet.OnlyWasm => kyo.internal.Platform.isWasm case _: PlatformSet.NotJvm => !kyo.internal.Platform.isJVM case _: PlatformSet.NotJs => !kyo.internal.Platform.isJS case _: PlatformSet.NotNative => !kyo.internal.Platform.isNative + case _: PlatformSet.NotWasm => !kyo.internal.Platform.isWasm -/** Phantom-typed carrier produced by a platform filter (`.jvm`, `.js`, `.native`, `.onlyJvm`, `.onlyJs`, `.onlyNative`, `.notJvm`, `.notJs`, - * `.notNative`). +/** Phantom-typed carrier produced by a platform filter (`.jvm`, `.js`, `.native`, `.wasm`, `.onlyJvm`, `.onlyJs`, `.onlyNative`, `.onlyWasm`, + * `.notJvm`, `.notJs`, `.notNative`, `.notWasm`). * * It wraps the runtime [[TestBuilder]] metadata and adds a phantom type parameter `P` naming the enabled-platform set (a [[PlatformSet]] * marker). The terminal `in`/`-` (defined as extensions on this type inside [[kyo.test.internal.TestBase]]) branch on `inline if @@ -126,7 +145,8 @@ transparent inline def gateOf[P]: Boolean = * UNAPPLIED via `discardScoped`/`discardGroup`, so its code is never emitted. * * Decorators chained after a platform filter (`.pending`, `.pendingUntilFixed`, `.focus`, `.retry`, `.timeout`, ...) preserve `P`, so a - * chain like `"x".notNative.pending("...") in { ... }` stays compile-excluded on Native. + * chain like `"x".notNative.pending("...") in { ... }` stays compile-excluded on Native. A second platform filter combines with `P` via + * [[PlatformSet.Both]] instead of replacing it, so `"x".notNative.notWasm in { ... }` is compile-excluded on both Native and WebAssembly. * * @tparam P * the [[PlatformSet]] marker naming the enabled-platform set diff --git a/kyo-test/api/shared/src/main/scala/kyo/test/internal/TestBase.scala b/kyo-test/api/shared/src/main/scala/kyo/test/internal/TestBase.scala index 2883d08d00..1cae252ce6 100644 --- a/kyo-test/api/shared/src/main/scala/kyo/test/internal/TestBase.scala +++ b/kyo-test/api/shared/src/main/scala/kyo/test/internal/TestBase.scala @@ -138,9 +138,11 @@ abstract class TestBase[S] extends KyoTestReflect with TypeCheck: def jvm: PlatformTestBuilder[PlatformSet.OnlyJvm] = PlatformTestBuilder(TestBuilder(name)) def js: PlatformTestBuilder[PlatformSet.OnlyJs] = PlatformTestBuilder(TestBuilder(name)) def native: PlatformTestBuilder[PlatformSet.OnlyNative] = PlatformTestBuilder(TestBuilder(name)) + def wasm: PlatformTestBuilder[PlatformSet.OnlyWasm] = PlatformTestBuilder(TestBuilder(name)) def notJvm: PlatformTestBuilder[PlatformSet.NotJvm] = PlatformTestBuilder(TestBuilder(name)) def notJs: PlatformTestBuilder[PlatformSet.NotJs] = PlatformTestBuilder(TestBuilder(name)) def notNative: PlatformTestBuilder[PlatformSet.NotNative] = PlatformTestBuilder(TestBuilder(name)) + def notWasm: PlatformTestBuilder[PlatformSet.NotWasm] = PlatformTestBuilder(TestBuilder(name)) /** Restrict this leaf to exactly JVM; the body is compile-excluded on JS and Native (absent, not skipped). */ def onlyJvm: PlatformTestBuilder[PlatformSet.OnlyJvm] = PlatformTestBuilder(TestBuilder(name)) @@ -151,6 +153,9 @@ abstract class TestBase[S] extends KyoTestReflect with TypeCheck: /** Restrict this leaf to exactly Native; the body is compile-excluded on JVM and JS (absent, not skipped). */ def onlyNative: PlatformTestBuilder[PlatformSet.OnlyNative] = PlatformTestBuilder(TestBuilder(name)) + /** Restrict this leaf to exactly WebAssembly; the body is compile-excluded on JVM, JS, and Native (absent, not skipped). */ + def onlyWasm: PlatformTestBuilder[PlatformSet.OnlyWasm] = PlatformTestBuilder(TestBuilder(name)) + end extension // ── DSL: extension on TestBuilder ──────────────────────────────── @@ -230,9 +235,11 @@ abstract class TestBase[S] extends KyoTestReflect with TypeCheck: def jvm: PlatformTestBuilder[PlatformSet.OnlyJvm] = PlatformTestBuilder(b) def js: PlatformTestBuilder[PlatformSet.OnlyJs] = PlatformTestBuilder(b) def native: PlatformTestBuilder[PlatformSet.OnlyNative] = PlatformTestBuilder(b) + def wasm: PlatformTestBuilder[PlatformSet.OnlyWasm] = PlatformTestBuilder(b) def notJvm: PlatformTestBuilder[PlatformSet.NotJvm] = PlatformTestBuilder(b) def notJs: PlatformTestBuilder[PlatformSet.NotJs] = PlatformTestBuilder(b) def notNative: PlatformTestBuilder[PlatformSet.NotNative] = PlatformTestBuilder(b) + def notWasm: PlatformTestBuilder[PlatformSet.NotWasm] = PlatformTestBuilder(b) /** Restrict this leaf to exactly JVM; the body is compile-excluded on JS and Native (absent, not skipped). */ def onlyJvm: PlatformTestBuilder[PlatformSet.OnlyJvm] = PlatformTestBuilder(b) @@ -243,6 +250,9 @@ abstract class TestBase[S] extends KyoTestReflect with TypeCheck: /** Restrict this leaf to exactly Native; the body is compile-excluded on JVM and JS (absent, not skipped). */ def onlyNative: PlatformTestBuilder[PlatformSet.OnlyNative] = PlatformTestBuilder(b) + /** Restrict this leaf to exactly WebAssembly; the body is compile-excluded on JVM, JS, and Native (absent, not skipped). */ + def onlyWasm: PlatformTestBuilder[PlatformSet.OnlyWasm] = PlatformTestBuilder(b) + end extension // ── DSL: extension on PlatformTestBuilder[P] ───────────────────────── @@ -312,6 +322,22 @@ abstract class TestBase[S] extends KyoTestReflect with TypeCheck: def only(cond: => Boolean): PlatformTestBuilder[P] = PlatformTestBuilder(pb.builder.copy(onlyIf = Maybe(() => cond))) + // A second platform filter combines with P via PlatformSet.Both rather than replacing it (gateOf reduces Both to an &&), + // so `.notNative.notWasm` is enabled only where both hold. Single-platform filters keep the `.jvm` == `.onlyJvm` identity. + + def jvm: PlatformTestBuilder[PlatformSet.Both[P, PlatformSet.OnlyJvm]] = PlatformTestBuilder(pb.builder) + def js: PlatformTestBuilder[PlatformSet.Both[P, PlatformSet.OnlyJs]] = PlatformTestBuilder(pb.builder) + def native: PlatformTestBuilder[PlatformSet.Both[P, PlatformSet.OnlyNative]] = PlatformTestBuilder(pb.builder) + def wasm: PlatformTestBuilder[PlatformSet.Both[P, PlatformSet.OnlyWasm]] = PlatformTestBuilder(pb.builder) + def notJvm: PlatformTestBuilder[PlatformSet.Both[P, PlatformSet.NotJvm]] = PlatformTestBuilder(pb.builder) + def notJs: PlatformTestBuilder[PlatformSet.Both[P, PlatformSet.NotJs]] = PlatformTestBuilder(pb.builder) + def notNative: PlatformTestBuilder[PlatformSet.Both[P, PlatformSet.NotNative]] = PlatformTestBuilder(pb.builder) + def notWasm: PlatformTestBuilder[PlatformSet.Both[P, PlatformSet.NotWasm]] = PlatformTestBuilder(pb.builder) + def onlyJvm: PlatformTestBuilder[PlatformSet.Both[P, PlatformSet.OnlyJvm]] = PlatformTestBuilder(pb.builder) + def onlyJs: PlatformTestBuilder[PlatformSet.Both[P, PlatformSet.OnlyJs]] = PlatformTestBuilder(pb.builder) + def onlyNative: PlatformTestBuilder[PlatformSet.Both[P, PlatformSet.OnlyNative]] = PlatformTestBuilder(pb.builder) + def onlyWasm: PlatformTestBuilder[PlatformSet.Both[P, PlatformSet.OnlyWasm]] = PlatformTestBuilder(pb.builder) + end extension // ── Assertion macros ────────────────────────────────────────────────────── 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/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) + } +} diff --git a/project/WithKyoTest.scala b/project/WithKyoTest.scala index a514a88102..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.* @@ -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 } } }