diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d219f65..c181cf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,10 +44,10 @@ jobs: # The `wasm` leg is the Phase-0 WASI-component prototype (docs/WASM-COMPONENTS-PROPOSAL.md §6). It # is a separate, opt-in sbt build in wasm/ (the scala-wasm fork of sbt-scalajs cannot share a # meta-build with mainline sbt-scalajs) and has its own steps below: format check, the wall's unit - # tests (on Node) and scripts/wasm/e2e.sh in source mode, then publishLocal of the six wasm artifacts + # tests (on Node) and scripts/wasm/e2e.sh in source mode, then publishLocal of the five wasm artifacts # and a plugin-only consumer build (scripts/wasm/consumer-check.sh). # - # The wasm leg GATES `publish`, like jvm and js: the publish job also publishes the six wasm + # The wasm leg GATES `publish`, like jvm and js: the publish job also publishes the five wasm # artifacts to Central (PUBLISH_WASM_TO_CENTRAL), so a broken wasm build must never reach a release. test: strategy: @@ -147,20 +147,20 @@ jobs: wget -q https://raw.githubusercontent.com/dapr/cli/v1.15.1/install/install.sh -O - | /bin/bash -s 1.15.1 dapr init --slim --runtime-version 1.17.7 echo "$HOME/.dapr/bin" >> "$GITHUB_PATH" - # The patched scala-wasm linker (scripts/wasm/build-patched-linker.sh: the 1.22.1-wasm.6 release - # commit + our export-params patch, published to ~/.ivy2/local). Rebuilt only when the script, the - # patch or the version in wasm/build.sbt changes. - # The patched scala-wasm linker (scripts/wasm/build-patched-linker.sh: the 1.22.1-wasm.6 release commit + - # our params-via-memory patch) is built by the `wasmLinker` project on demand; cache its output and the - # scala-wasm clone. Rebuilt only when the script or the patch changes. - - name: Cache the patched scala-wasm linker build + # The UNRELEASED scala-wasm the wasm/ build pins (1.22.1-wasm.7-dapr4s-local: scala-wasm's branch + + # the open upstream PRs dapr4s needs, see scripts/wasm/build-scala-wasm.sh). Built from source and + # publishLocal'ed; cached by the script's hash (it pins the commit SHAs and the version). Goes away + # with the next scala-wasm release on Central. + - name: Cache the scala-wasm snapshot build if: matrix.platform == 'wasm' + id: cache-scala-wasm uses: actions/cache@v4 with: - path: | - wasm/target/scala-wasm-linker - ~/.cache/dapr4s/scala-wasm-src - key: scala-wasm-linker-${{ hashFiles('scripts/wasm/build-patched-linker.sh', 'scripts/wasm/patches/**') }} + path: ~/.ivy2/local/io.github.scala-wasm + key: scala-wasm-snapshot-${{ hashFiles('scripts/wasm/build-scala-wasm.sh') }} + - name: Build and publishLocal the scala-wasm snapshot + if: matrix.platform == 'wasm' + run: scripts/wasm/build-scala-wasm.sh # Source mode: the wall over dapr4s's own src/shared (covers shared-code changes), unit tests + e2e # (counter-raw, counter, imports-probe against daprd + Redis). - name: Format check, wall unit tests (wasm/, source mode) @@ -171,7 +171,7 @@ jobs: if: matrix.platform == 'wasm' timeout-minutes: 20 run: SBT_ARGS=-Ddapr4s.wasm.core=source scripts/wasm/e2e.sh - # Release mode (the default): the six artifacts as they would be published (the wall on the published + # Release mode (the default): the five artifacts as they would be published (the wall on the published # dapr4s_sjs1_3), publishLocal'ed, then a fresh consumer build that has nothing but the plugin + one # dependency builds test/wasm/counter and runs its e2e. dapr4s-examples consumes the same artifacts. - name: publishLocal (release mode) @@ -204,7 +204,10 @@ jobs: # The switch for Central publishing of the wasm artifacts (ON since 2026-10-13, approved by the # user). Setting it to 'false' stops only the wasm artifacts; core (dapr4s_3, dapr4s_sjs1_3) # publishes regardless. - PUBLISH_WASM_TO_CENTRAL: 'true' + # OFF while wasm/ pins the unreleased scala-wasm 1.22.1-wasm.7-dapr4s-local + # (scripts/wasm/build-scala-wasm.sh): sbt-dapr4s-wasm's POM would name an sbt-scalajs version that + # is not on Central, so no consumer could resolve it. Back to 'true' with a scala-wasm release. + PUBLISH_WASM_TO_CENTRAL: 'false' steps: - uses: actions/checkout@v5 with: @@ -246,7 +249,7 @@ jobs: PUBLISH_SECRET_KEY: ${{ secrets.PUBLISH_SECRET_KEY }} PGP_PASSPHRASE: ${{ secrets.PUBLISH_SECRET_KEY_PASSWORD }} # ---- The wasm artifacts (wasm/ build): dapr4s-wasm, dapr4s-wasm-glue(-exports), dapr4s-wasm-scalalib-overlay, - # dapr4s-wasm-linker, sbt-dapr4s-wasm — at the same version as core, the wall depending on the dapr4s_sjs1_3 + # sbt-dapr4s-wasm — at the same version as core, the wall depending on the dapr4s_sjs1_3 # of THIS version (published locally first so the wasm build resolves it before Central has it). # Gated by PUBLISH_WASM_TO_CENTRAL (the wasm build also refuses `publish` unless that variable is 'true', # so a local `sbt publish` in wasm/ can never reach Central by accident). See AGENTS.md "Wasm component layer rules". diff --git a/AGENTS.md b/AGENTS.md index ad0b033..f49b8fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -351,19 +351,29 @@ The `wasm` platform builds a dapr4s app as a WASI **component** for [dapr-wasm-components](https://github.com/sideeffffect/dapr-wasm-components) 0.7.5, with the scala-wasm fork of Scala.js (`io.github.scala-wasm:sbt-scalajs`, `ModuleKind.WasmComponent`). Status, blockers and numbers: `docs/WASM-COMPONENTS-PROPOSAL.md` §6 and -`wiki/scala-js/scala-wasm-component-spike.md`. The six artifacts are **published to Maven Central** -by the same `publish` job as core (snapshots on master pushes, releases on tags); `publishLocal` works. +`wiki/scala-js/scala-wasm-component-spike.md`. The five artifacts are **published to Maven Central** +by the same `publish` job as core (snapshots on master pushes, releases on tags) — but that switch is +OFF while `wasm/` pins an unreleased scala-wasm (below); `publishLocal` works. Upstream gaps are worked around here AND fixed upstream (the earlier "no upstream PRs" rule was lifted on 2026-10-13). Open upstream PRs, each retiring one workaround: scala-wasm/scala-wasm -[#268](https://github.com/scala-wasm/scala-wasm/pull/268) (params via memory → patched linker), -[#269](https://github.com/scala-wasm/scala-wasm/pull/269) (WIT aliases → re-embed), +[#268](https://github.com/scala-wasm/scala-wasm/pull/268) (params via memory → patched linker; merged, RETIRED here), +[#269](https://github.com/scala-wasm/scala-wasm/pull/269) (WIT aliases → re-embed; RETIRED here), [#270](https://github.com/scala-wasm/scala-wasm/pull/270) (pluggable System → println/clock rules), -[#271](https://github.com/scala-wasm/scala-wasm/pull/271) (core-lib exclusions), +[#271](https://github.com/scala-wasm/scala-wasm/pull/271) (core-lib exclusions; the org.scala-js ones RETIRED here), scala/scala3 [#27300](https://github.com/scala/scala3/pull/27300) (unpickler crash → empty javadoc), cquiroz/scala-java-locales [#640](https://github.com/cquiroz/scala-java-locales/pull/640) (java.time formatting). **scala/scala3 and VirtusLab/scala-cli forbid LLM-written PR/issue text** (their `LLM_POLICY.md`): there, agents push branches / open drafts with an EMPTY description; the user writes the text. +- **The pinned scala-wasm is an unreleased local build**: `1.22.1-wasm.7-dapr4s-local` + (`wasm/project/plugins.sbt`), built by `scripts/wasm/build-scala-wasm.sh` from scala-wasm's + `scala-wasm` branch (with #268) + a merge of the heads of #269 and #271, all pinned by SHA, and + `publishLocal`'ed to `~/.ivy2/local` (idempotent, stamp = the SHAs; ~10 min cold). Run it once + before the first `cd wasm && sbt ...`. The CI wasm leg runs it, cached by the script's hash. Bump the + SHAs (and the version, if the artifacts change) when the upstream PRs move. **RETIRES** with a + scala-wasm release that has all three: pin it, delete the script and its CI steps, and turn + `PUBLISH_WASM_TO_CENTRAL` back on (OFF meanwhile: `sbt-dapr4s-wasm`'s POM would name an + `sbt-scalajs` that is not on Central). - **Opt-in by construction — a nested sbt build in `wasm/`** (`wasm/build.sbt`, `wasm/project/`, `wasm/sbt-plugin/`). WHY: the scala-wasm plugin is a fork of `org.scala-js:sbt-scalajs` with the same classes and keys, so one meta-build cannot load both, and switching the root build to the @@ -371,7 +381,7 @@ agents push branches / open drafts with an EMPTY description; the user writes th plain `sbt test` works without any wasm tool. Run it with `cd wasm && sbt ...`. `wasm/build.sbt` reads `scala3Nightly`, `scalaJavaTime` and `munit` from the root `build.sbt` (single source of truth); keep its `compilerFlags` in sync by hand. -- **The six artifacts** (all `com.github.sideeffffect`, at core's sbt-dynver version via +- **The five artifacts** (all `com.github.sideeffffect`, at core's sbt-dynver version via sbt-ci-release, POM metadata as core's, normal suffixes): | artifact | project | contains | |---|---|---| @@ -379,12 +389,11 @@ agents push branches / open drafts with an EMPTY description; the user writes th | `dapr4s-wasm-glue_sjs1_2.13` | `witGlue` | the wit-bindgen-scala bindings of world `app` + the hand-written 2.13 glue | | `dapr4s-wasm-glue-exports_sjs1_2.13` | `witExports` | the hand-written `@WitExport` trampolines (a COMPONENT's dependency only; the plugin adds it) | | `dapr4s-wasm-scalalib-overlay` | `scalalibOverlay` | the fork-patched stdlib `.sjsir` (unmodified, from `io.github.scala-wasm:scalajs-scalalib_2.13:3.8.3-RC1-wasm.4`) + LICENSE/NOTICE attribution | - | `dapr4s-wasm-linker_2.12` | `wasmLinker` | scala-wasm's 1.22.1-wasm.6 linker rebuilt with our patch (`scripts/wasm/patches/scala-wasm-params-via-memory.patch`), LICENSE/NOTICE, patched sources in the sources jar; depends on the released ir/linker-interface | | `sbt-dapr4s-wasm` | `sbtDapr4sWasm` | the build logic (below) | - **Core: published or source.** By default (`CoreRelease` in `wasm/build.sbt`, or `-Ddapr4s.wasm.core=`) the wall depends on the PUBLISHED `dapr4s_sjs1_3`, whose Scala.js IR the - scala-wasm linker links into the component (the JS wall is unreachable and dropped; mainline - org.scala-js library/javalib are excluded). Valid because the wasm platform traits + scala-wasm linker links into the component (the JS wall is unreachable and dropped; the scala-wasm + plugin excludes the mainline org.scala-js library/javalib). Valid because the wasm platform traits (`src/wasm/core`) equal the JS ones (all empty). `-Ddapr4s.wasm.core=source` compiles `src/shared` + `src/wasm/core` as `coreWasm` instead, so shared-code changes are tested (nothing publishes in that mode). A release publishes the wasm artifacts against the core of the SAME version (the @@ -392,42 +401,44 @@ agents push branches / open drafts with an EMPTY description; the user writes th - **The consumer** needs only: `addSbtPlugin("com.github.sideeffffect" % "sbt-dapr4s-wasm" % v)`, `.enablePlugins(Dapr4sWasmComponentPlugin)`, - `libraryDependencies += "com.github.sideeffffect" %%% "dapr4s-wasm" % dapr4sWasmVersion`, its own - `wit/world.wit` (`include dapr-wasm-components:interfaces/app@0.7.5;`) + `wkg-config.toml` + - `wkg.lock`, and the fixed-name `object dapr4s.wasm.ComponentRoot { def component: DaprComponent = ... }`. + `libraryDependencies += "com.github.sideeffffect" %%% "dapr4s-wasm" % dapr4sWasmVersion` and the + fixed-name `object dapr4s.wasm.ComponentRoot { def component: DaprComponent = ... }`. No WIT files, + no wkg (the linker's component is used as is). `scripts/wasm/consumer-check.sh` builds `test/wasm/counter` exactly so (fresh build, nothing else) and runs its e2e; dapr4s-examples' `wasm/` build is the worked example. - **The plugin** (`wasm/sbt-plugin/.../Dapr4sWasmPlugin.scala`, every workaround documented at its definition with how it RETIRES): `Dapr4sWasmPlugin` (every nightly-compiled project) excludes the - 2.13 stdlib and mainline `org.scala-js` library/javalib and sets the linker to - `dapr4s-wasm-linker` (`dapr4sWasmLinkerClasspath`); `Dapr4sWasmComponentPlugin` (every component) + fork's 2.13 stdlib (`io.github.scala-wasm:scalajs-scalalib_2.13`, which #271 leaves next to the + nightly's); `Dapr4sWasmComponentPlugin` (every component) sets `ModuleKind.WasmComponent`, prepends `dapr4s-wasm-scalalib-overlay` to the linker classpath (`dapr4sWasmScalalibOverlay`, resolved through the linker's GLOBAL `dependencyResolution` because coursier's per-project one applies `excludeDependencies`), adds `dapr4s-wasm-glue-exports` - (`dapr4sWasmAddGlueExports`), runs `wkg wit fetch` and provides `dapr4sComponent` / - `dapr4sComponentFull` (re-embed the real WIT world with `--encoding utf16` + `component new`). The + (`dapr4sWasmAddGlueExports`) and provides `dapr4sComponent` / `dapr4sComponentFull` (copy the + linker's component as is). The same source is compiled into `wasm/`'s meta-build (`wasm/project/plugin-sources.sbt`); `wasm/build.sbt` - points the two artifact-backed keys at its own `wasmLinker` / `scalalibOverlay` projects and sets + points the artifact-backed key at its own `scalalibOverlay` project and sets `dapr4sWasmAddGlueExports := false` on its test components (they depend on `witExports` directly). -- **How each workaround retires**: patched linker → when a scala-wasm release passes WIT params through - linear memory on both sides, drop `dapr4s-wasm-linker` and the plugin's linker override; overlay → when - the nightly's library-js is Wasm-clean, drop `dapr4s-wasm-scalalib-overlay`; WIT re-embed → when the - linker keeps WIT aliases (or reads the WIT world), use its component as is; exclusions → when the - fork's library and mainline's can evict each other; the 2.13 glue → when Scala 3 gets WIT codegen. -- **The patched linker** (`scripts/wasm/build-patched-linker.sh`, run by `wasmLinker` on demand; - idempotent, stamp = commit + patch hash): the released linker leaves the EXPORT side of - params-via-memory (> 16 flat params) a `???` (`on-topic-event`, `on-timer` of the mandatory callbacks) - and silently emits NOTHING on the IMPORT side (`jobs.schedule`, `conversation.converse`, - `actors.register-reminder`/`-timer`; `component new` then fails "expected i32 but nothing on stack"). - The patch implements both (canonical ABI `lift_flat_values` / `lower_flat_values`). - `test/wasm/imports-probe` calls the four imports; the e2e checks it imports exactly them and runs - them against daprd (daprd's answers name the component/actor the probe sent). +- **How each workaround retires**: the local scala-wasm build → a scala-wasm release with #268, #269, + #271; overlay → when the nightly's library-js is Wasm-clean, drop `dapr4s-wasm-scalalib-overlay`; + the fork-scalalib exclusion → when the scala-wasm plugin stops adding its 2.13 scalalib to Scala 3 + projects; `useWitAliases` (`wasm/build.sbt`: rewrites the generated bindings so a `use`d alias such as + `type Metadata` points at `types.Metadata`, the only form #269 keeps) → when wit-bindgen-scala renders + `use`d aliases so; the fork's `scalajs-junit-test-runtime` on `wallWasm/Test` (#271 excludes the + upstream one munit needs, adds no replacement) → when the plugin swaps it; the scaladoc-less + `dapr4s-wasm` javadoc jar → a nightly with scala/scala3#27300 (none yet: the newest, 2026-10-15, + predates the merge); the 2.13 glue → when Scala 3 gets WIT codegen. +- **Retired** (2026-10-16, verified by `scripts/wasm/e2e.sh` + `consumer-check.sh`): the patched linker + `dapr4s-wasm-linker` (#268 lowers > 16 flat params through linear memory on both sides; the + `test/wasm/imports-probe` e2e runs `jobs.schedule`, `conversation.converse`, + `actors.register-reminder`/`-timer` against daprd), the WIT re-embed (`component embed --encoding + utf16` + `component new`; the linker's component, with #269 + `useWitAliases`, composes with the + providers as is) and the `org.scala-js` library/javalib exclusions (#271). - **WIT only through OCI**: `world app { include dapr-wasm-components:interfaces/app@0.7.5; }` + the upstream `wkg-config.toml`; `wkg wit fetch --config wkg-config.toml` runs before wit-bindgen-scala. `wkg.lock` is committed, `wasm/wit/deps/` is git-ignored. Never copy WIT from git. - **Newest everything**: the glue tracks the WIT version (`wasm/wit/world.wit`, the `WitScope` versions in the trampolines). Always the newest dapr-wasm-components WIT release (now 0.7.5), the - newest scala-wasm sbt plugin on Central (now 1.22.1-wasm.6) and the newest wit-bindgen-scala (0.1.0). + newest scala-wasm (now the local build above; on Central 1.22.1-wasm.6) and the newest wit-bindgen-scala (0.1.0). - **The two-compiler constraint**: mainline Scala 3 has no WIT codegen (imports are lowered at the call site by the compiler backend, `@WitExport` is compiler-emitted). Every import call and every export must be compiled by Scala 2.13 + scala-wasm's compiler plugin. The 2.13 code is @@ -455,17 +466,19 @@ agents push branches / open drafts with an EMPTY description; the user writes th `dapr4s.internal.witglue.AppRoot`. The stubs are never published and are filtered out of the POMs (on a linker classpath they would shadow the real objects: first IR file per class wins). - **Central publishing switch**: the `publish` job of `.github/workflows/ci.yml` sets - `PUBLISH_WASM_TO_CENTRAL: 'true'` (ON since 2026-10-13, approved by the user); the wasm build keeps + `PUBLISH_WASM_TO_CENTRAL` (ON since 2026-10-13, approved by the user; `'false'` while the local + scala-wasm build is pinned); the wasm build keeps `publish / skip` unless that variable is `'true'`, so local `sbt publish` in `wasm/` never reaches Central. The job publishes `coreJS` locally and runs `sbt -Ddapr4s.wasm.core= ci-release` in `wasm/`. - **Shared code on the `wasm` path** must avoid `java.time` formatting/parsing, wall-clock (`Instant.now`), `println` and `UUID.randomUUID`: they reach JS and fail to link. -- Commands (tools: `wkg`, `wasm-tools`, `wit-bindgen-scala` for the build; plus Docker, the dapr +- Commands (first `scripts/wasm/build-scala-wasm.sh`; tools: `wkg`, `wasm-tools`, `wit-bindgen-scala` + for the build; plus Docker, the dapr CLI + daprd from `dapr init --slim`, `wasmtime` and `wac` for the e2e): - `cd wasm && sbt wallWasm/test` (wall unit tests, plain Scala.js on Node; add `-Ddapr4s.wasm.core=source` for the source mode) - `cd wasm && sbt counter/dapr4sComponent` → `wasm/target/components/counter.wasm` - - `cd wasm && sbt publishLocal` (the six artifacts, for dapr4s-examples) + - `cd wasm && sbt publishLocal` (the five artifacts, for dapr4s-examples) - `scripts/wasm/e2e.sh` (counter-raw, counter, imports-probe: imports check, compose, daprd + Redis, for the probe also a `daprio/dapr` scheduler container and daprd's echo conversation component; the probe asserts `converse` → `ok: `, `schedule` → `ok: scheduled …`, register-reminder/-timer on an unhosted actor type → @@ -509,7 +522,7 @@ Four legs, exactly as CI runs them (`.github/workflows/ci.yml`, one `test` matri `jvm` and a `js` include entry — a future Scala Native port is a new include entry, not a new job; each leg runs compile + unit tests + integration tests). A third include entry, `wasm`, runs the opt-in `wasm/` build (format check, compile, `wallWasm/test`, `scripts/wasm/e2e.sh`; see -"Wasm component layer rules"). It installs its pinned tools, builds and caches the patched linker, +"Wasm component layer rules"). It installs its pinned tools, builds and caches the pinned scala-wasm snapshot, and gates `publish` like the other legs (the wasm artifacts are published by the same job). - **JVM unit tests** (no Docker): `sbt coreJVM/test`. Shared diff --git a/docs/WASM-COMPONENTS-PROPOSAL.md b/docs/WASM-COMPONENTS-PROPOSAL.md index d1faf5a..d4109aa 100644 --- a/docs/WASM-COMPONENTS-PROPOSAL.md +++ b/docs/WASM-COMPONENTS-PROPOSAL.md @@ -225,8 +225,8 @@ The spike was ported into this repository ([PR #44](https://github.com/sideeffff [`test/wasm/imports-probe`](../test/wasm/imports-probe) (the four imports with > 16 flat params). - **Scripts**: [`scripts/wasm/`](../scripts/wasm): `e2e.sh` (import checks, compose, daprd + Redis, counter 1 → 2, error mapping, the imports probe against daprd's echo conversation component and a scheduler container), - `consumer-check.sh`, `compose.sh`, `run.sh`, `measure.py`, - `build-patched-linker.sh` + `patches/`. + `consumer-check.sh`, `compose.sh`, `run.sh`, `measure.py`, `build-scala-wasm.sh` (the pinned scala-wasm + snapshot, see below). - **CI**: a `wasm` entry in the `test` matrix (source-mode unit tests + e2e, release-mode publishLocal + consumer check). It gates `publish` like the jvm and js legs. - **Packaging (2026-10-13): six artifacts, published to Central.** All `com.github.sideeffffect`, at core's @@ -246,6 +246,26 @@ The spike was ported into this repository ([PR #44](https://github.com/sideeffff fixed upstream (scala-wasm #268–#271, scala3 #27300; see AGENTS.md): the linker patch now covers BOTH sides of params-via-memory (the import side was silently missing in 1.22.1-wasm.6), proven by `test/wasm/imports-probe` against daprd. +### Retiring workarounds on an unreleased scala-wasm (2026-10-16) + +[PR #46](https://github.com/sideeffffect/dapr4s/pull/46) (draft, +stacked on #44) pins `1.22.1-wasm.7-dapr4s-local`: scala-wasm's `scala-wasm` branch at `32cda138` (with +[scala-wasm#268](https://github.com/scala-wasm/scala-wasm/pull/268), merged) + a local merge of the open +[#269](https://github.com/scala-wasm/scala-wasm/pull/269) (`be1184cd`) and +[#271](https://github.com/scala-wasm/scala-wasm/pull/271) (`e23da14d`). `scripts/wasm/build-scala-wasm.sh` builds and +`publishLocal`s it (CI: cached by the script hash). Central publishing of the wasm artifacts is off meanwhile. + +| Workaround | Status | Why | +|---|---|---| +| Patched linker `dapr4s-wasm-linker` | **removed** (artifact, script, patch) | #268: params through linear memory on both sides. The imports probe runs all four > 16-param imports against daprd. | +| WIT re-embed (`component embed --encoding utf16` + `component new`) | **removed** | #269 keeps a `@WitAlias` reached through a plain type alias. The linker's component (UTF-16 already) composes with the providers as is. Apps need no WIT files and no wkg. | +| — | **new, small**: `useWitAliases` in `wasm/build.sbt` | wit-bindgen-scala 0.1.0 renders a `use`d alias structurally (`type Metadata = Array[...]`), so #269 alone does not help. The rewrite makes it `type Metadata = types.Metadata`. Without it, compose fails ("missing expected list export `metadata`"), checked. | +| `org.scala-js` library/javalib exclusions | **removed** | #271 excludes them in the scala-wasm plugin. Checked on the classpaths. | +| Fork's 2.13 scalalib exclusion | kept | #271 keeps the upstream scalalib on Scala 3.8+ and leaves the fork's 2.13 one next to it. | +| — | **new, small**: fork's `scalajs-junit-test-runtime` on `wallWasm/Test` | #271 also excludes the upstream JUnit runtime that munit needs, and adds no fork one. | +| Empty javadoc jar of `dapr4s-wasm` | kept | [scala/scala3#27300](https://github.com/scala/scala3/pull/27300) merged 2026-10-15 07:43; the newest nightly (`3.10.2-RC1-bin-20261015-88be2f5`) is from before it. | +| Stdlib overlay | kept | unrelated to these PRs. | + This makes the "two-compiler build" of the revised Phase 4 concrete: the 2.13 part is the generated bindings plus a small hand-written glue that grows with the wall (a generator can come back once it covers ~100 WIT functions). What remains open for Phase 4 is resources, the other walls, retiring the workarounds as scala-wasm ships fixes, and switching the diff --git a/scripts/wasm/build-patched-linker.sh b/scripts/wasm/build-patched-linker.sh deleted file mode 100755 index 4d783be..0000000 --- a/scripts/wasm/build-patched-linker.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -# Build scala-wasm's linker at the exact 1.22.1-wasm.6 release commit + dapr4s's patch -# (scripts/wasm/patches/scala-wasm-params-via-memory.patch) and leave the jar (+ sources jar) in -# wasm/target/scala-wasm-linker/. The `wasmLinker` project of wasm/build.sbt republishes it, under -# dapr4s's coordinates, as com.github.sideeffffect:dapr4s-wasm-linker_2.12 (with scala-wasm's LICENSE -# and NOTICE); the sbt-dapr4s-wasm plugin links every app with it. It runs this script itself. -# -# WHY: the released linker cannot pass WIT function params through linear memory, which the canonical -# ABI requires once the flattened params exceed MAX_FLAT_PARAMS = 16: the EXPORT side is a `???` -# (`on-topic-event`, `on-timer` of the mandatory callbacks hit it) and the IMPORT side silently emits -# nothing (`jobs.schedule`, `conversation.converse`, `actors.register-reminder/-timer` hit it). The -# patch implements both. See wiki/scala-js/scala-wasm-component-spike.md (blocker 4). Nothing is sent -# upstream (decision): the patch lives here. -# -# Only the linker module changes; ir, linker-interface, library, javalib and the compiler plugin stay -# the released 1.22.1-wasm.6 artifacts (the patch touches no shared API), so the republished linker -# depends on the released ir/linker-interface. -# -# Idempotent: skips the build when the stamp (commit + patch hash) matches (FORCE=1 rebuilds). -set -euo pipefail -here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo="$(cd "$here/../.." && pwd)" -patch="$here/patches/scala-wasm-params-via-memory.patch" -# "1.22.1-wasm.6" (scala-wasm/scala-wasm, merged as #266). No GitHub tag exists for it; the sbt plugin -# 1.22.1-wasm.6 on Maven Central was released from this commit. -rev=e24a46b6f1df14483a56c7c02b1787b68bae29a8 -out="${OUT:-$repo/wasm/target/scala-wasm-linker}" -stamp="$rev $(sha256sum "$patch" | cut -c1-16)" -if [[ -f "$out/stamp" && "$(cat "$out/stamp")" == "$stamp" && -s "$out/scalajs-linker_2.12.jar" && "${FORCE:-0}" != 1 ]]; then - echo "build-patched-linker.sh: up to date ($stamp) in $out (FORCE=1 to rebuild)" - exit 0 -fi -work="${WORK:-${XDG_CACHE_HOME:-$HOME/.cache}/dapr4s/scala-wasm-src}" -if [[ ! -d "$work/.git" ]]; then - git clone --filter=blob:none --no-checkout https://github.com/scala-wasm/scala-wasm.git "$work" -fi -cd "$work" -git fetch -q origin "$rev" 2>/dev/null || true -git checkout -q -f "$rev" -git clean -q -fdx -e target -git apply "$patch" -# Stale jars of earlier builds (other versions) would be picked up below: start clean. -find linker -path '*scala-2.12*' -name 'scalajs-linker_2.12-*.jar' -delete -sbt -batch -no-colors linker2_12/package linker2_12/packageSrc -mkdir -p "$out" -jar="$(find linker -path '*scala-2.12*' -name 'scalajs-linker_2.12-*.jar' ! -name '*-sources.jar' ! -name '*-javadoc.jar')" -src="$(find linker -path '*scala-2.12*' -name 'scalajs-linker_2.12-*-sources.jar')" -[[ -s "$jar" && -s "$src" && "$(wc -l <<<"$jar")" == 1 ]] || { echo "build-patched-linker.sh: built jars not found: $jar $src" >&2; exit 1; } -unzip -p "$src" org/scalajs/linker/backend/wasmemitter/canonicalabi/InteropEmitter.scala | grep -q lower_flat_values || - { echo "build-patched-linker.sh: the built sources lack the patch" >&2; exit 1; } -cp "$jar" "$out/scalajs-linker_2.12.jar" -cp "$src" "$out/scalajs-linker_2.12-sources.jar" -cp LICENSE NOTICE "$out/" -echo "$stamp" >"$out/stamp" -echo "build-patched-linker.sh: built the patched scala-wasm linker ($stamp) into $out" diff --git a/scripts/wasm/build-scala-wasm.sh b/scripts/wasm/build-scala-wasm.sh new file mode 100755 index 0000000..a9da46e --- /dev/null +++ b/scripts/wasm/build-scala-wasm.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Build an UNRELEASED scala-wasm from source and publishLocal it (to ~/.ivy2/local) as version +# $SCALA_WASM_VERSION. The wasm/ build (project/plugins.sbt, build.sbt) uses exactly this version. +# +# WHAT: the scala-wasm/scala-wasm `scala-wasm` branch at $BASE, plus a merge of the heads of the open +# upstream PRs dapr4s depends on: +# - BASE includes https://github.com/scala-wasm/scala-wasm/pull/268 (merged): WIT params through +# linear memory beyond 16 flat params, both sides. Retires dapr4s's patched linker. +# - https://github.com/scala-wasm/scala-wasm/pull/269 (open): keep @WitAlias behind a plain type +# alias. Retires the WIT re-embed (`component embed` + `component new`) in sbt-dapr4s-wasm. +# - https://github.com/scala-wasm/scala-wasm/pull/271 (open): the sbt plugin excludes the upstream +# org.scala-js core libraries. Retires those exclusions in sbt-dapr4s-wasm. +# WHY: those fixes are not in a scala-wasm release yet (newest on Central: 1.22.1-wasm.6). Nothing is +# pushed anywhere: the merge exists only in the local clone. +# RETIRES when a scala-wasm release contains all of them: pin that release in wasm/project/plugins.sbt +# and wasm/build.sbt, and delete this script and its CI steps. +# +# Idempotent: skips the build when the stamp (the three SHAs + the version) matches (FORCE=1 rebuilds). +# The CI wasm leg caches ~/.ivy2/local/io.github.scala-wasm keyed by this file's hash. +set -euo pipefail +SCALA_WASM_VERSION="${SCALA_WASM_VERSION:-1.22.1-wasm.7-dapr4s-local}" +BASE=32cda1384fb5a64899103136dd0b09869423c01c # scala-wasm branch, "Merge pull request #268" +PR269=be1184cd1e524476e5efb74d46b4bff14108fe13 +PR271=e23da14d9dd32ee55a8f8aef4c6225c7c468a68c +ivy="${IVY_LOCAL:-$HOME/.ivy2/local}" +stampFile="$ivy/io.github.scala-wasm/dapr4s-build-stamp-$SCALA_WASM_VERSION" +stamp="$BASE $PR269 $PR271" +plugin="$ivy/io.github.scala-wasm/sbt-scalajs/scala_2.12/sbt_1.0/$SCALA_WASM_VERSION/jars/sbt-scalajs.jar" +if [[ -f "$stampFile" && "$(cat "$stampFile")" == "$stamp" && -s "$plugin" && "${FORCE:-0}" != 1 ]]; then + echo "build-scala-wasm.sh: $SCALA_WASM_VERSION up to date in $ivy (FORCE=1 to rebuild)" + exit 0 +fi +work="${WORK:-${XDG_CACHE_HOME:-$HOME/.cache}/dapr4s/scala-wasm-snapshot-src}" +if [[ ! -d "$work/.git" ]]; then + git clone --filter=blob:none --no-checkout https://github.com/scala-wasm/scala-wasm.git "$work" +fi +cd "$work" +for ref in "$BASE" pull/269/head pull/271/head; do git fetch -q origin "$ref"; done +git cat-file -e "$PR269^{commit}" && git cat-file -e "$PR271^{commit}" || + { echo "build-scala-wasm.sh: a pinned PR head is gone from GitHub; re-pin it" >&2; exit 1; } +git checkout -q -f --detach "$BASE" +git clean -q -fdx -e target +g() { git -c user.name=dapr4s -c user.email=dapr4s@localhost "$@"; } +g merge -q --no-edit "$PR269" +g merge -q --no-edit "$PR271" +f=ir/shared/src/main/scala/org/scalajs/ir/ScalaJSVersions.scala +sed -i -E "s/current = \"[^\"]+\"/current = \"$SCALA_WASM_VERSION\"/" "$f" +grep -q "current = \"$SCALA_WASM_VERSION\"" "$f" || { echo "build-scala-wasm.sh: cannot set the version" >&2; exit 1; } +# The modules the wasm/ build resolves: the sbt plugin and its 2.12 dependencies (ir, linker-interface, +# linker, test adapter), javalib, and the 2.13 side (library, scalalib, compiler plugin, test bridge). +sbt -batch -no-colors \ + ir2_12/publishLocal linkerInterface2_12/publishLocal linker2_12/publishLocal testAdapter2_12/publishLocal \ + sbtPlugin2_12/publishLocal javalib/publishLocal javalibintf/publishLocal \ + ir2_13/publishLocal linkerInterface2_13/publishLocal linker2_13/publishLocal testAdapter2_13/publishLocal \ + library2_13/publishLocal scalalib2_13/publishLocal compiler2_13/publishLocal \ + testInterface2_13/publishLocal testBridge2_13/publishLocal jUnitRuntime2_13/publishLocal jUnitPlugin2_13/publishLocal +git checkout -q -- "$f" +[[ -s "$plugin" ]] || { echo "build-scala-wasm.sh: $plugin was not published" >&2; exit 1; } +echo "$stamp" >"$stampFile" +echo "build-scala-wasm.sh: published scala-wasm $SCALA_WASM_VERSION ($stamp) to $ivy" diff --git a/scripts/wasm/consumer-check.sh b/scripts/wasm/consumer-check.sh index e116f03..39abcad 100755 --- a/scripts/wasm/consumer-check.sh +++ b/scripts/wasm/consumer-check.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # Builds the dapr4s test component test/wasm/counter exactly like an external app does: a fresh sbt build # (wasm/target/consumer-check) with ONLY `addSbtPlugin(sbt-dapr4s-wasm)`, `enablePlugins(Dapr4sWasmComponentPlugin)`, -# one libraryDependency (`dapr4s-wasm`), the app's own WIT world + wkg files, and the fixed-name ComponentRoot — no -# in-build workaround. Everything comes from `cd wasm && sbt publishLocal` (run it first) and Maven Central. Then +# one libraryDependency (`dapr4s-wasm`) and the fixed-name ComponentRoot — no WIT files, no wkg, no in-build +# workaround. Everything comes from `cd wasm && sbt publishLocal` (run it first), Maven Central and, while +# wasm/ pins an unreleased scala-wasm, that snapshot in ~/.ivy2/local (scripts/wasm/build-scala-wasm.sh). Then # runs the counter part of scripts/wasm/e2e.sh on the result. set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -11,10 +12,8 @@ version="${DAPR4S_WASM_VERSION:-$(cd "$repo/wasm" && sbt -batch -no-colors 'prin nightly="$(sed -n 's/^val scala3Nightly = "\(.*\)"$/\1/p' "$repo/build.sbt")" dir="$repo/wasm/target/consumer-check" rm -rf "$dir" -mkdir -p "$dir/project" "$dir/wit" "$dir/src" +mkdir -p "$dir/project" "$dir/src" cp "$repo/wasm/project/build.properties" "$dir/project/" -cp "$repo/wasm/wit/world.wit" "$dir/wit/" -cp "$repo/wasm/wkg-config.toml" "$repo/wasm/wkg.lock" "$dir/" cp "$repo"/test/wasm/counter/*.scala "$dir/src/" cat >"$dir/project/plugins.sbt" < 1, then 2; error mapping: # missing-store -> ComponentNotFoundException(State, "no-such-store"); stale-etag -> # ETagMismatchException and the stale write does not land (counter -> 3) -# importsProbe (test/wasm/imports-probe): the four imports with > 16 flat params, which only the -# patched linker (dapr4s-wasm-linker) lowers: it must import exactly them; `converse` to daprd's +# importsProbe (test/wasm/imports-probe): the four imports with > 16 flat params, which the linker +# lowers through linear memory (scala-wasm/scala-wasm#268): it must import exactly them; `converse` to daprd's # echo component must answer the very text the probe sent (the params reached the host intact); # `schedule` must succeed (a scheduler runs for it); register-timer / register-reminder on an # actor type this app does not host must be daprd's typed refusal (PermissionDenied), the timer's diff --git a/scripts/wasm/patches/scala-wasm-params-via-memory.patch b/scripts/wasm/patches/scala-wasm-params-via-memory.patch deleted file mode 100644 index 1d534f9..0000000 --- a/scripts/wasm/patches/scala-wasm-params-via-memory.patch +++ /dev/null @@ -1,96 +0,0 @@ -diff --git a/linker/shared/src/main/scala/org/scalajs/linker/backend/wasmemitter/canonicalabi/CABIToScalaJS.scala b/linker/shared/src/main/scala/org/scalajs/linker/backend/wasmemitter/canonicalabi/CABIToScalaJS.scala -index 632b871f6..b2dd217f1 100644 ---- a/linker/shared/src/main/scala/org/scalajs/linker/backend/wasmemitter/canonicalabi/CABIToScalaJS.scala -+++ b/linker/shared/src/main/scala/org/scalajs/linker/backend/wasmemitter/canonicalabi/CABIToScalaJS.scala -@@ -514,6 +514,26 @@ object CABIToScalaJS { - } - } - -+ /** Loads the parameters of a lifted (exported) function from linear memory. -+ * -+ * When the flattened parameters exceed MAX_FLAT_PARAMS (16), the canonical ABI passes a single -+ * i32 pointer to a tuple of all parameters (`lift_flat_values`: `ptr = vi.next('i32')`, -+ * `load(cx, ptr, TupleType(ts))`). Each parameter is aligned to its own alignment and the values -+ * are left on the Wasm stack in declaration order, like `genLoadStack` does. -+ */ -+ def genLoadParamsFromMemory(fb: FunctionBuilder, paramTypes: List[wit.ValType], -+ paramsPtr: wanme.LocalID)(implicit ctx: WasmContext): Unit = { -+ val ptr = fb.addLocal(NoOriginalName, watpe.Int32) -+ fb += wa.LocalGet(paramsPtr) -+ fb += wa.LocalSet(ptr) -+ for (tpe <- paramTypes) { -+ genAlignTo(fb, WitTypeOps.alignment(tpe), ptr) -+ fb += wa.LocalGet(ptr) -+ genLoadMemory(fb, tpe) -+ genMovePtr(fb, ptr, WitTypeOps.elemSize(tpe)) -+ } -+ } -+ - private def genAlignTo(fb: FunctionBuilder, align: Int, ptr: wanme.LocalID): Unit = { - // ;; Calculate: (ptr + alignment - 1) / alignment * alignment - // ptr + alignment - 1 -diff --git a/linker/shared/src/main/scala/org/scalajs/linker/backend/wasmemitter/canonicalabi/InteropEmitter.scala b/linker/shared/src/main/scala/org/scalajs/linker/backend/wasmemitter/canonicalabi/InteropEmitter.scala -index 508cf5154..077c5e000 100644 ---- a/linker/shared/src/main/scala/org/scalajs/linker/backend/wasmemitter/canonicalabi/InteropEmitter.scala -+++ b/linker/shared/src/main/scala/org/scalajs/linker/backend/wasmemitter/canonicalabi/InteropEmitter.scala -@@ -101,8 +101,38 @@ object InteropEmitter { - - // adapt params to CanonicalABI - loweredFuncType.paramsOffset match { -- case Some(offset) => -- // TODO : put params onto linear memory -+ case Some(_) => -+ // The flattened params exceed MAX_FLAT_PARAMS (16): the canonical ABI (`lower_flat_values`) -+ // passes a single i32 pointer to a tuple of all params, which the caller allocates and fills -+ // (`store(cx, v, TupleType(ts), ptr)`): each param aligned to its own alignment, the tuple -+ // aligned to the max of them. `malloc` is 8-aligned and the memory is released below, like -+ // every other temporary of this adapter, by restoring the saved stack pointer. -+ val paramTypes = params.map(_._2) -+ val offsets = { -+ var off = 0 -+ paramTypes.map { tpe => -+ val align = WitTypeOps.alignment(tpe) -+ off = ((off + align - 1) / align) * align -+ val o = off -+ off += WitTypeOps.elemSize(tpe) -+ o -+ } -+ } -+ val tupleAlign = (1 :: paramTypes.map(WitTypeOps.alignment(_))).max -+ val endOff = (1 :: offsets.zip(paramTypes).map { case (o, t) => o + WitTypeOps.elemSize(t) }).max -+ val tupleSize = ((endOff + tupleAlign - 1) / tupleAlign) * tupleAlign -+ val paramsPtr = fb.addLocal(NoOriginalName, watpe.Int32) -+ fb += wa.I32Const(tupleSize) -+ fb += wa.Call(genFunctionID.malloc) -+ fb += wa.LocalSet(paramsPtr) -+ params.zip(offsets).foreach { case ((localID, tpe), off) => -+ fb += wa.LocalGet(paramsPtr) -+ fb += wa.I32Const(off) -+ fb += wa.I32Add -+ fb += wa.LocalGet(localID) -+ ScalaJSToCABI.genStoreMemory(fb, tpe) -+ } -+ fb += wa.LocalGet(paramsPtr) - case None => - params.foreach { case (localID, tpe) => - fb += wa.LocalGet(localID) -@@ -196,7 +226,18 @@ object InteropEmitter { - fb += wa.Call(genFunctionID.loadModule(owningClass)) - - flatFuncType.paramsOffset match { -- case Some(paramsOffset) => ??? // TODO read params from linear memory -+ case Some(_) => -+ val paramsPtr = fb.addParam(OriginalName("params_ptr"), watpe.Int32) -+ // The host allocated the parameter tuple with cabi_realloc (our bump allocator) before -+ // this call; release it in post-return like strings are released in genLoadMemory. -+ fb += wa.GlobalGet(genGlobalID.savedStackPointer) -+ fb += wa.LocalGet(paramsPtr) -+ fb += wa.I32GtU -+ fb.ifThen() { -+ fb += wa.LocalGet(paramsPtr) -+ fb += wa.GlobalSet(genGlobalID.savedStackPointer) -+ } -+ CABIToScalaJS.genLoadParamsFromMemory(fb, exportDef.signature.paramTypes, paramsPtr) - case None => - val vi = flatFuncType.stackParams.map { t => - (fb.addParam(NoOriginalName, t), t) diff --git a/src/wasm/glue/WitGlue.scala b/src/wasm/glue/WitGlue.scala index dcedfff..6b44c00 100644 --- a/src/wasm/glue/WitGlue.scala +++ b/src/wasm/glue/WitGlue.scala @@ -117,9 +117,9 @@ object StateImports extends StateImports { } /** The imports whose flattened params exceed MAX_FLAT_PARAMS = 16, so the canonical ABI passes them through linear - * memory — which the released scala-wasm linker does not implement (dapr4s-wasm-linker does). The wall does not use - * them yet; test/wasm/imports-probe calls each one to prove the patched linker lowers them (scripts/wasm/e2e.sh). - * `jobs.schedule` (`dapr-wasm-components:interfaces/jobs@0.7.5`). + * memory — which the scala-wasm linker implements since https://github.com/scala-wasm/scala-wasm/pull/268 (not in + * 1.22.1-wasm.6). The wall does not use them yet; test/wasm/imports-probe calls each one to prove the linker lowers + * them (scripts/wasm/e2e.sh). `jobs.schedule` (`dapr-wasm-components:interfaces/jobs@0.7.5`). */ trait JobsImports { def schedule(name: String, job: jobs_read.Job, overwrite: Boolean): wit.Result[Unit, jobs.ScheduleError] diff --git a/test/wasm/imports-probe/Probe.scala b/test/wasm/imports-probe/Probe.scala index 9127ff3..90e33bb 100644 --- a/test/wasm/imports-probe/Probe.scala +++ b/test/wasm/imports-probe/Probe.scala @@ -9,13 +9,13 @@ import dapr4swit.dapr_wasm_components.interfaces.{actors, conversation, invocati import dapr4swit.exports.dapr_wasm_components.interfaces.invocation_callback /** Calls each WIT import whose flattened params exceed MAX_FLAT_PARAMS = 16 (params passed through linear memory), - * which the released scala-wasm linker cannot lower and dapr4s-wasm-linker can. Linking this component at all proves - * the import side is emitted; scripts/wasm/e2e.sh also checks that the component imports exactly these four, and runs - * them against a real daprd (dapr-wasm-components 0.7.5 providers): `converse` goes to daprd's `conversation.echo` - * component, so the answer must be the very text the probe sent, which proves the params (passed through linear - * memory) reach the host intact; `register-timer` on a type this app does not host must be daprd's typed refusal - * naming the actor the probe sent; `schedule` / `register-reminder` must come back as well-formed WIT results. Each - * route answers `ok: ...` or `error: `. + * which the scala-wasm linker lowers since https://github.com/scala-wasm/scala-wasm/pull/268 (the 1.22.1-wasm.6 + * release could not). Linking this component at all proves the import side is emitted; scripts/wasm/e2e.sh also checks + * that the component imports exactly these four, and runs them against a real daprd (dapr-wasm-components 0.7.5 + * providers): `converse` goes to daprd's `conversation.echo` component, so the answer must be the very text the probe + * sent, which proves the params (passed through linear memory) reach the host intact; `register-timer` on a type this + * app does not host must be daprd's typed refusal naming the actor the probe sent; `schedule` / `register-reminder` + * must come back as well-formed WIT results. Each route answers `ok: ...` or `error: `. */ object ProbeHandler extends witglue.InvocationCallbackExports: private def respond(status: Int, text: String): invocation.HttpResponse = diff --git a/wasm/build.sbt b/wasm/build.sbt index 23defee..b2fee18 100644 --- a/wasm/build.sbt +++ b/wasm/build.sbt @@ -24,9 +24,7 @@ // COMPONENT's dependency only (the plugin adds it) // dapr4s-wasm-scalalib-overlay scalalibOverlay: the fork-patched stdlib .sjsir (no suffix: // not Scala code, an IR overlay) -// dapr4s-wasm-linker_2.12 wasmLinker: scala-wasm's linker with dapr4s's params-via-memory -// patch (built by ../scripts/wasm/build-patched-linker.sh) -// sbt-dapr4s-wasm (sbt 1, 2.12) sbtDapr4sWasm: the build logic (exclusions, linker, overlay, +// sbt-dapr4s-wasm (sbt 1, 2.12) sbtDapr4sWasm: the build logic (exclusions, overlay, // wkg wit fetch, dapr4sComponent) // // Never published: the link-time STUBS (witRootStub, componentRootStub; `Provided`, so they never @@ -78,10 +76,6 @@ lazy val compilerFlags = Seq( // wiki/scala-js/scala-wasm-component-spike.md, blocker 2. val scala2Wit = "2.13.18" -// The Scala of the released scala-wasm 1.22.1-wasm.6 linker (its POM's scala-library). -val scala2Linker = "2.12.21" -val scalaWasmVersion = "1.22.1-wasm.6" - // The scala-wasm sbt plugin this build runs on (project/plugins.sbt), which the published sbt-dapr4s-wasm plugin // depends on. Read from plugins.sbt so the two cannot drift. val scalaWasmSbtPluginVersion = @@ -154,8 +148,8 @@ lazy val published = Seq( // WIT dependencies come from OCI via wkg, never from git: `wit/world.wit` includes // `dapr-wasm-components:interfaces/app@0.7.5`, `wkg-config.toml` is the upstream file that maps the // namespace to ghcr.io, and `wkg wit fetch` writes wit/deps/ (git-ignored) + wkg.lock (committed, -// pins the package digest). Runs before wit-bindgen-scala. (Same command as the plugin's -// `dapr4sWasmWitFetch`, which component projects run before re-embedding the WIT world.) +// pins the package digest). Runs before wit-bindgen-scala. Only this build needs the WIT (to +// generate dapr4s-wasm-glue's bindings); apps and components do not. // ------------------------------------------------------------------------------------------------- lazy val witFetch = taskKey[Unit]("Fetch WIT dependencies with `wkg wit fetch` (OCI)") ThisBuild / witFetch := Dapr4sWasm.witFetch(witDir, streams.value.log) @@ -201,6 +195,52 @@ def withCore(p: Project): Project = // covering only what the Scala 3 wall uses. See src/wasm/glue/WitGlue.scala for why it must be 2.13. // ------------------------------------------------------------------------------------------------- +// WHAT: rewrites the generated bindings in place so that every `use`d WIT type ALIAS points at its +// defining interface: in `invocation_callback`, `type Metadata = Array[wit.Tuple2[String, String]]` +// becomes `type Metadata = dapr4swit.dapr_wasm_components.interfaces.types.Metadata`. +// WHY: wit-bindgen-scala 0.1.0 renders a `use` of a type alias structurally (its `render_type` writes +// named list/option/result/tuple typedefs out), so the `@WitAlias` of `types.metadata` is lost and the +// linker's component-type has no `metadata` type export in the exported `*-callback` instances; +// `wac compose` with the dapr-wasm-components inbound provider then fails ("instance is missing +// expected list export `metadata`"). The scala-wasm compiler keeps an alias reached through a plain +// type alias since https://github.com/scala-wasm/scala-wasm/pull/269 — but only in this form. +// WHY SAFE: a rewrite replaces a type with an alias of the SAME structural type (both sides are +// checked equal before rewriting), so no Scala type changes; only the alias the compiler sees does. +// It applies only to an un-annotated `type X = R` whose name has exactly one `@WitAlias` definition +// `type X = R` (same R) elsewhere in the bindings, i.e. a `use`. Idempotent: a rewritten line no longer +// matches. Verified by scripts/wasm/e2e.sh, which composes the linker's component AS IS. +// RETIRES when wit-bindgen-scala renders `use`d aliases as references to the defining interface. +def useWitAliases(files: Seq[File]): Seq[File] = { + val TypeLine = """^ type (\w+) = (.+)$""".r + def objectOf(src: String): String = { + val pkg = """(?m)^package ([\w.]+)$""".r.findFirstMatchIn(src).map(_.group(1)) + val obj = """(?m)^package object (\w+)""".r.findFirstMatchIn(src).map(_.group(1)) + (pkg.toList ++ obj.toList).mkString(".") + } + // name -> (FQN of the defining package object, right-hand side), for every @WitAlias definition. + val defs = files.flatMap { f => + val lines = IO.readLines(f) + lines.indices.collect { + case i if i > 0 && lines(i - 1).contains("@scala.scalajs.wit.annotation.WitAlias(") => + lines(i) match { case TypeLine(n, rhs) => Some((n, (objectOf(IO.read(f)), rhs))); case _ => None } + }.flatten + } + val unique = defs.groupBy(_._1).collect { case (n, Seq((_, d))) => n -> d } + files.foreach { f => + val lines = IO.readLines(f) + val rewritten = lines.indices.map { i => + val annotated = i > 0 && lines(i - 1).contains("@scala.scalajs.wit.annotation.WitAlias(") + lines(i) match { + case TypeLine(n, rhs) if !annotated && unique.get(n).exists(_._2 == rhs) => + s" type $n = ${unique(n)._1}.$n" + case l => l + } + } + if (rewritten != lines) IO.writeLines(f, rewritten) + } + files +} + // The bindings of world `app` under `dapr4swit` (a dapr4s-specific root package, like `dapr4styped` for // the ScalablyTyped facades, so a consumer's own wit-bindgen output cannot collide with it) + the // hand-written import traits/objects and the 7 `*CallbackExports` traits. Scala 3 uses the binding types @@ -214,10 +254,14 @@ lazy val witGlue = project scalaJSWitDirectory := witDir, scalaJSWitWorld := Some("app"), scalaJSWitPackage := Some("dapr4swit"), - // The plugin also runs the generator for Test; both must wait for wit/deps/ (sbt runs witFetch - // once per command). - Compile / scalaJSGenerateWitBindings := (Compile / scalaJSGenerateWitBindings).dependsOn(witFetch).value, - Test / scalaJSGenerateWitBindings := (Test / scalaJSGenerateWitBindings).dependsOn(witFetch).value, + // The generator must wait for wit/deps/ (sbt runs witFetch once per command). useWitAliases: see + // its definition. The plugin also runs the generator for Test, into the SAME directory (it writes to + // `Compile / sourceManaged` in both configurations): the two runs in parallel corrupted each other's + // files (and the rewrite's). This project has no tests and Test sees the main classes anyway, so Test + // generates nothing. + Compile / scalaJSGenerateWitBindings := + useWitAliases((Compile / scalaJSGenerateWitBindings).dependsOn(witFetch).value), + Test / scalaJSGenerateWitBindings := Nil, ) // Hand-written link-time stub of `dapr4s.internal.witglue.AppRoot` (src/wasm/glue-root-stub). @@ -302,13 +346,24 @@ lazy val wallWasm = withCore( // plain Scala.js (this project keeps the default JS linker config) and run on Node: no WIT import is // reachable from them, so they need neither wasmtime nor a sidecar. Test / unmanagedSourceDirectories := Seq(repoDir / "test" / "wasm" / "unit"), - libraryDependencies += "org.scalameta" %%% "munit" % munitVersion % Test, + libraryDependencies ++= Seq( + "org.scalameta" %%% "munit" % munitVersion % Test, + // WHAT: the fork's JUnit runtime, added by hand. WHY: munit_sjs1 depends on the UPSTREAM + // org.scala-js:scalajs-junit-test-runtime_2.13 (it holds org.junit.ComparisonFailure, which munit's + // assertions throw), and the scala-wasm plugin now excludes every upstream core library + // (https://github.com/scala-wasm/scala-wasm/pull/271) but adds the fork's JUnit runtime only to + // projects with its JUnit plugin. Without it the test link fails ("Referring to non-existent class + // org.junit.ComparisonFailure"). WHY SAFE: the same artifact at the fork's own version, so the + // classes munit links against are exactly the upstream ones it was built for. RETIRES when the plugin + // swaps the excluded runtime for the fork's (or munit stops needing it). + ("io.github.scala-wasm" % "scalajs-junit-test-runtime_2.13" % scalaJSVersion % Test), + ), ) .dependsOn(wasmApi, wallInert, witGlue, componentRootStub % Provided), ) // ------------------------------------------------------------------------------------------------- -// The build-level artifacts: the stdlib overlay, the patched linker, the sbt plugin +// The build-level artifacts: the stdlib overlay, the sbt plugin // ------------------------------------------------------------------------------------------------- // The .sjsir of the classes scala-wasm's Scala 3 fork patched in library-js (scala-wasm/scala3@17a0deeb: @@ -360,57 +415,6 @@ lazy val scalalibOverlay = project Compile / packageDoc / mappings := Seq(baseDirectory.value / "NOTICE" -> "NOTICE"), ) -// scala-wasm's 1.22.1-wasm.6 linker with dapr4s's params-via-memory patch, republished under dapr4s's -// coordinates. ../scripts/wasm/build-patched-linker.sh builds it (from a clone of scala-wasm at the -// release commit; idempotent) into target/scala-wasm-linker/; its dependencies are the RELEASED ir and -// linker-interface (the patch touches only the linker module). -lazy val patchedLinkerJars = taskKey[File]("Build the patched scala-wasm linker (build-patched-linker.sh)") -lazy val wasmLinker = project - .in(file("linker")) - .settings(published) - .settings( - name := "dapr4s-wasm-linker", - scalaVersion := scala2Linker, - scalacOptions := Nil, - libraryDependencies ++= Seq( - "io.github.scala-wasm" %% "scalajs-linker-interface" % scalaWasmVersion, - "io.github.scala-wasm" %% "scalajs-ir" % scalaWasmVersion, - "com.google.javascript" % "closure-compiler" % "v20220202", - ), - patchedLinkerJars := { - val out = wasmDir / "target" / "scala-wasm-linker" - val log = streams.value.log - val exit = scala.sys.process - .Process(Seq((repoDir / "scripts" / "wasm" / "build-patched-linker.sh").getPath), repoDir, "OUT" -> out.getPath) - .!(scala.sys.process.ProcessLogger(log.info(_), log.info(_))) - if (exit != 0) sys.error(s"build-patched-linker.sh failed ($exit)") - out - }, - Compile / packageBin / mappings := { - val out = patchedLinkerJars.value - val dir = target.value / "linker-classes" - IO.delete(dir) - IO.unzip(out / "scalajs-linker_2.12.jar", dir, (n: String) => !n.startsWith("META-INF/MANIFEST")) - val notice = target.value / "NOTICE" - IO.write(notice, IO.read(baseDirectory.value / "NOTICE-dapr4s") + IO.read(out / "NOTICE")) - (dir ** -DirectoryFilter).get.map(f => f -> IO.relativize(dir, f).get) ++ Seq( - out / "LICENSE" -> "META-INF/LICENSE", - notice -> "META-INF/NOTICE", - ) - }, - Compile / packageSrc / mappings := { - val out = patchedLinkerJars.value - val dir = target.value / "linker-sources" - IO.delete(dir) - IO.unzip(out / "scalajs-linker_2.12-sources.jar", dir, (n: String) => !n.startsWith("META-INF/MANIFEST")) - (dir ** -DirectoryFilter).get.map(f => f -> IO.relativize(dir, f).get) ++ Seq( - repoDir / "scripts" / "wasm" / "patches" / "scala-wasm-params-via-memory.patch" -> - "scala-wasm-params-via-memory.patch", - ) - }, - Compile / packageDoc / mappings := Seq(baseDirectory.value / "NOTICE-dapr4s" -> "NOTICE"), - ) - // The sbt-dapr4s-wasm plugin (an app adds it with addSbtPlugin; it brings the scala-wasm fork of // sbt-scalajs). Same sources as this build's meta-build (project/plugin-sources.sbt). lazy val sbtDapr4sWasm = project @@ -428,10 +432,8 @@ lazy val sbtDapr4sWasm = project addSbtPlugin("io.github.scala-wasm" % "sbt-scalajs" % scalaWasmSbtPluginVersion), ) -// This build links with its OWN linker and overlay projects (the plugin, run from the meta-build, has -// no published version to resolve them by), exactly the artifacts an app gets from the plugin. -Global / dapr4sWasmLinkerClasspath := - (wasmLinker / Compile / packageBin).value +: (wasmLinker / Compile / dependencyClasspath).value.files +// This build links with its OWN overlay project (the plugin, run from the meta-build, has no published +// version to resolve it by), exactly the artifact an app gets from the plugin. ThisBuild / dapr4sWasmScalalibOverlay := Seq((scalalibOverlay / Compile / packageBin).value) // ------------------------------------------------------------------------------------------------- @@ -453,7 +455,8 @@ lazy val counter = project .dependsOn(wallWasm, witExports) // Calls the four WIT imports with > 16 flat params (jobs.schedule, conversation.converse, -// actors.register-reminder/-timer): proves the patched linker lowers them (scripts/wasm/e2e.sh). +// actors.register-reminder/-timer): proves the linker lowers them through linear memory +// (https://github.com/scala-wasm/scala-wasm/pull/268; scripts/wasm/e2e.sh). lazy val importsProbe = project .in(file("imports-probe")) .enablePlugins(Dapr4sWasmComponentPlugin) @@ -473,7 +476,6 @@ lazy val root = project wallInert, wallWasm, scalalibOverlay, - wasmLinker, sbtDapr4sWasm, counterRaw, counter, diff --git a/wasm/linker/NOTICE-dapr4s b/wasm/linker/NOTICE-dapr4s deleted file mode 100644 index d467257..0000000 --- a/wasm/linker/NOTICE-dapr4s +++ /dev/null @@ -1,12 +0,0 @@ -dapr4s-wasm-linker - -This artifact is the linker of scala-wasm (https://github.com/scala-wasm/scala-wasm), a fork of Scala.js, -at its 1.22.1-wasm.6 release commit e24a46b6f1df14483a56c7c02b1787b68bae29a8, built with one patch by -dapr4s (scripts/wasm/patches/scala-wasm-params-via-memory.patch in https://github.com/sideeffffect/dapr4s): -WIT function params are passed through linear memory when their flattened count exceeds MAX_FLAT_PARAMS -(16), for both exports (lifting) and imports (lowering). Republished under dapr4s's coordinates. The -sources jar contains the patched sources. - -Licensed under the Apache License, Version 2.0 (see LICENSE). The scala-wasm NOTICE follows. - ----------------------------------------------------------------------------------------------------- diff --git a/wasm/project/plugins.sbt b/wasm/project/plugins.sbt index 9447ed7..5c7e19c 100644 --- a/wasm/project/plugins.sbt +++ b/wasm/project/plugins.sbt @@ -1,7 +1,11 @@ // The scala-wasm fork of sbt-scalajs (WasmComponent module kind, WIT bindings via wit-bindgen-scala). // It shares classes and keys with org.scala-js:sbt-scalajs, which is why this is a separate build -// from the repo root (see build.sbt). 1.22.1-wasm.6 is the newest on Maven Central (2026-10-13). -addSbtPlugin("io.github.scala-wasm" % "sbt-scalajs" % "1.22.1-wasm.6") +// from the repo root (see build.sbt). +// 1.22.1-wasm.7-dapr4s-local is NOT on Maven Central: ../scripts/wasm/build-scala-wasm.sh builds it from +// scala-wasm's `scala-wasm` branch + the open upstream PRs dapr4s needs, and publishLocal's it (the CI +// wasm leg runs it, cached). Back to a Central release (1.22.1-wasm.6 was the last one) once scala-wasm +// releases those fixes. +addSbtPlugin("io.github.scala-wasm" % "sbt-scalajs" % "1.22.1-wasm.7-dapr4s-local") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.6.2") // Same as the root build: sbt-ci-release (sbt-dynver version from git, so one commit gives core and the wasm artifacts // one version; publishSigned + Sonatype for the release workflow, which keeps Central publishing OFF for now). diff --git a/wasm/sbt-plugin/src/main/scala/dapr4s/sbtwasm/Dapr4sWasmPlugin.scala b/wasm/sbt-plugin/src/main/scala/dapr4s/sbtwasm/Dapr4sWasmPlugin.scala index 1d2aff5..fc79a49 100644 --- a/wasm/sbt-plugin/src/main/scala/dapr4s/sbtwasm/Dapr4sWasmPlugin.scala +++ b/wasm/sbt-plugin/src/main/scala/dapr4s/sbtwasm/Dapr4sWasmPlugin.scala @@ -9,27 +9,26 @@ import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport._ /** The build logic a dapr4s `wasm` app needs, as an sbt plugin (`sbt-dapr4s-wasm`). * - * WHY a plugin and not a library: everything here is a workaround in the BUILD, not in code — which linker links the - * app, which stdlib IR wins on the linker classpath, which transitive libraries must not reach the classpath, and how - * the linked module becomes a component typed by the real WIT world. A library cannot change any of that. dapr4s's own - * `wasm/` build compiles this very file into its meta-build (`wasm/project/plugin-sources.sbt`) and points the two - * artifact-backed keys (`dapr4sWasmLinkerClasspath`, `dapr4sWasmScalalibOverlay`) at its own projects, so the test - * components in dapr4s and an external app (dapr4s-examples) build the same way. + * WHY a plugin and not a library: everything here is BUILD configuration, not code — the WasmComponent link, which + * stdlib IR wins on the linker classpath, which transitive libraries must not reach the classpath. A library cannot + * change any of that. dapr4s's own `wasm/` build compiles this very file into its meta-build + * (`wasm/project/plugin-sources.sbt`) and points the artifact-backed key (`dapr4sWasmScalalibOverlay`) at its own + * project, so the test components in dapr4s and an external app (dapr4s-examples) build the same way. * * Two plugins, both opt-in (`enablePlugins`), on top of the scala-wasm FORK of sbt-scalajs * (`io.github.scala-wasm:sbt-scalajs`), which this plugin depends on: * * - [[Dapr4sWasmPlugin]]: every project compiled by the Scala 3 nightly against dapr4s's wasm artifacts (library or - * app): dependency exclusions + the patched linker (`dapr4s-wasm-linker`). + * app): dependency exclusions. * - [[Dapr4sWasmComponentPlugin]]: a project linked as a WASI component (an app): WasmComponent link, the stdlib - * overlay (`dapr4s-wasm-scalalib-overlay`), the `@WitExport` trampolines (`dapr4s-wasm-glue-exports`), - * `wkg wit fetch` and `dapr4sComponent` / `dapr4sComponentFull`. + * overlay (`dapr4s-wasm-scalalib-overlay`), the `@WitExport` trampolines (`dapr4s-wasm-glue-exports`) and + * `dapr4sComponent` / `dapr4sComponentFull`. * * An app's whole setup: * {{{ * // project/plugins.sbt * addSbtPlugin("com.github.sideeffffect" % "sbt-dapr4s-wasm" % "") - * // build.sbt (+ wit/world.wit, wkg-config.toml, wkg.lock next to it) + * // build.sbt * lazy val app = project.enablePlugins(Dapr4sWasmComponentPlugin) * .settings(libraryDependencies += "com.github.sideeffffect" %%% "dapr4s-wasm" % dapr4sWasmVersion) * // + `object dapr4s.wasm.ComponentRoot { def component: DaprComponent = MyComponent }` @@ -53,41 +52,19 @@ object Dapr4sWasmPlugin extends AutoPlugin { .getOrElse( sys.error("dapr4sWasmVersion: no Implementation-Version (not running from the published plugin jar)"), ) - - val dapr4sWasmLinkerClasspath = - taskKey[Seq[File]]("Classpath of the linker that links the app: dapr4s-wasm-linker_2.12 + its dependencies") } - import autoImport._ - - // ----------------------------------------------------------------------------------------------- - // Upstream blocker (scala-wasm linker: WIT params via linear memory, > 16 flat params): the export - // side is a `???`, the import side emits nothing. Link with dapr4s-wasm-linker_2.12, the - // 1.22.1-wasm.6 linker rebuilt with dapr4s's patch (scripts/wasm/patches/), republished under - // dapr4s's coordinates with scala-wasm's LICENSE/NOTICE. Only the LINKER is replaced; the plugin, - // library, javalib and compiler plugin stay the released ones. RETIRES when a scala-wasm release - // passes params via memory: drop this override. - // ----------------------------------------------------------------------------------------------- - override def globalSettings: Seq[Setting[_]] = Seq( - dapr4sWasmLinkerClasspath := { - val s = streams.value - Dapr4sWasm.retrieve( - (scalaJSLinkerImpl / dependencyResolution).value, - Dapr4sWasm.Org % "dapr4s-wasm-linker_2.12" % dapr4sWasmVersion, - s.cacheDirectory / "dapr4s-wasm-linker", - s.log, - ) - }, - scalaJSLinkerImpl / fullClasspath := dapr4sWasmLinkerClasspath.value.map(Attributed.blank(_)), - ) // ----------------------------------------------------------------------------------------------- // A nightly-compiled project that depends on 2.13-compiled artifacts (dapr4s's WIT glue) must NOT - // see the 2.13 stdlib the scala-wasm plugin adds to those (different organization, so no eviction - // against the nightly's): drop it. dapr4s core (dapr4s_sjs1_3) and other cross-built dependencies - // (scala-java-time, upickle, ...) bring MAINLINE Scala.js (scalajs-library -> scalajs-javalib). - // Different organization than the fork's, so no eviction either: both would be on the classpath and - // the first IR file per class would win. Keep only the fork's. RETIRES when the fork's library and - // mainline's can evict each other (same coordinates) or mainline Scala.js gains the component target. + // see the fork's 2.13 stdlib (io.github.scala-wasm:scalajs-scalalib_2.13:2.13.18+) that the + // scala-wasm plugin adds to those: it is a different organization AND version line than the + // nightly's own Scala.js stdlib (org.scala-js:scalajs-scalalib_2.13:), so nothing evicts + // it, both end up on the classpath and the first IR file per class wins. Drop the fork's. + // (The UPSTREAM org.scala-js core libraries -- scalajs-library, scalajs-javalib, ... -- that dapr4s + // core and other cross-built dependencies bring are excluded by the scala-wasm plugin itself since + // https://github.com/scala-wasm/scala-wasm/pull/271, which keeps the upstream scalalib on Scala + // 3.8+ and leaves this duplicate open.) RETIRES when the scala-wasm plugin drops the 2.13 scalalib + // from Scala 3 projects. // ----------------------------------------------------------------------------------------------- override def projectSettings: Seq[Setting[_]] = Seq( excludeDependencies ++= Dapr4sWasm.exclusions, @@ -100,25 +77,18 @@ object Dapr4sWasmComponentPlugin extends AutoPlugin { override def trigger: PluginTrigger = noTrigger object autoImport { - val dapr4sWasmWitDirectory = - settingKey[File]("WIT directory: world.wit (`include dapr-wasm-components:interfaces/app@...`) + deps/ from wkg") - val dapr4sWasmWorld = settingKey[String]("WIT world the component targets (default: app)") val dapr4sWasmComponentsDirectory = settingKey[File]("Where dapr4sComponent writes .wasm") - val dapr4sWasmWitFetch = - taskKey[Unit]("`wkg wit fetch --config wkg-config.toml` next to the WIT directory (WIT only through OCI)") val dapr4sWasmScalalibOverlay = taskKey[Seq[File]]("The fork-patched stdlib .sjsir (dapr4s-wasm-scalalib-overlay), first on the linker classpath") val dapr4sWasmAddGlueExports = settingKey[Boolean]("Add dapr4s-wasm-glue-exports (the @WitExport trampolines) to libraryDependencies") - val dapr4sComponent = taskKey[File]("Linked app (fastLinkJS) as a WASI component typed by the real WIT world") + val dapr4sComponent = taskKey[File]("Linked app (fastLinkJS) as a WASI component") val dapr4sComponentFull = taskKey[File]("Like dapr4sComponent, from fullLinkJS (optimized)") } import autoImport._ import Dapr4sWasmPlugin.autoImport.dapr4sWasmVersion override def buildSettings: Seq[Setting[_]] = Seq( - dapr4sWasmWitDirectory := (LocalRootProject / baseDirectory).value / "wit", - dapr4sWasmWitFetch := Dapr4sWasm.witFetch(dapr4sWasmWitDirectory.value, streams.value.log), // ----------------------------------------------------------------------------------------------- // Upstream blocker (the nightly's Scala.js stdlib, org.scala-js:scalajs-scalalib_2.13:, // still uses JS interop: BoxesRunTime.equals uses JS `===`, reachable from any program; Symbol's @@ -141,7 +111,6 @@ object Dapr4sWasmComponentPlugin extends AutoPlugin { ) override def projectSettings: Seq[Setting[_]] = Seq( - dapr4sWasmWorld := "app", dapr4sWasmComponentsDirectory := (LocalRootProject / target).value / "components", dapr4sWasmAddGlueExports := true, // The trampolines are a COMPONENT's dependency only: every `@WitExport` is a linker entry point, @@ -162,27 +131,23 @@ object Dapr4sWasmComponentPlugin extends AutoPlugin { }, Compile / fullClasspath := dapr4sWasmScalalibOverlay.value.map(Attributed.blank(_)) ++ (Compile / fullClasspath).value, + // The linker's output IS the component: its component-type, synthesized from the IR signatures, + // keeps the WIT type aliases the dapr-wasm-components providers need (`metadata` in the exported + // `*-callback` instances) since https://github.com/scala-wasm/scala-wasm/pull/269, given bindings + // that reference the defining interface (dapr4s-wasm-glue's are rewritten so, see `useWitAliases` + // in dapr4s's wasm/build.sbt). It lowers strings as UTF-16 itself. Nothing to re-embed: an app + // needs no WIT files and no wkg (wasm-tools: only the one the scala-wasm linker runs itself). dapr4sComponent := { - dapr4sWasmWitFetch.value (Compile / fastLinkJS).value - Dapr4sWasm.buildComponent( - streams.value.log, + Dapr4sWasm.copyComponent( (Compile / fastLinkJS / scalaJSLinkerOutputDirectory).value / "main.wasm", - target.value / "component-fast", - dapr4sWasmWitDirectory.value, - dapr4sWasmWorld.value, dapr4sWasmComponentsDirectory.value / s"${name.value}.wasm", ) }, dapr4sComponentFull := { - dapr4sWasmWitFetch.value (Compile / fullLinkJS).value - Dapr4sWasm.buildComponent( - streams.value.log, + Dapr4sWasm.copyComponent( (Compile / fullLinkJS / scalaJSLinkerOutputDirectory).value / "main.wasm", - target.value / "component-full", - dapr4sWasmWitDirectory.value, - dapr4sWasmWorld.value, dapr4sWasmComponentsDirectory.value / s"${name.value}-opt.wasm", ) }, @@ -193,14 +158,7 @@ object Dapr4sWasm { val Org = "com.github.sideeffffect" val ScalaWasmOrg = "io.github.scala-wasm" - val exclusions: Seq[ExclusionRule] = Seq( - ExclusionRule(ScalaWasmOrg, "scala-library"), - ExclusionRule(ScalaWasmOrg, "scala3-library_3"), - ExclusionRule(ScalaWasmOrg, "scala3-library_sjs1_3"), - ExclusionRule(ScalaWasmOrg, "scalajs-scalalib_2.13"), - ExclusionRule("org.scala-js", "scalajs-library_2.13"), - ExclusionRule("org.scala-js", "scalajs-javalib"), - ) + val exclusions: Seq[ExclusionRule] = Seq(ExclusionRule(ScalaWasmOrg, "scalajs-scalalib_2.13")) def retrieve(lm: sbt.librarymanagement.DependencyResolution, module: ModuleID, dir: File, log: Logger): Seq[File] = { val files = lm @@ -217,6 +175,8 @@ object Dapr4sWasm { files } + // `wkg wit fetch` for dapr4s's own build (the WIT world its dapr4s-wasm-glue bindings are generated + // from). Apps need no WIT any more (see dapr4sComponent). def witFetch(witDir: File, log: Logger): Unit = { val base = witDir.getParentFile val cmd = Seq("wkg", "wit", "fetch", "--config", (base / "wkg-config.toml").getPath) @@ -225,82 +185,9 @@ object Dapr4sWasm { if (exit != 0) sys.error(s"wkg wit fetch failed ($exit)") } - private def extractMainCoreModule(component: File, out: File): Unit = { - val bytes = IO.readBytes(component) - require( - bytes.length > 8 && bytes(0) == 0 && bytes(1) == 'a' && bytes(2) == 's' && bytes(3) == 'm', - s"$component is not a wasm binary", - ) - var pos = 8 - def leb(): Int = { - var result = 0 - var shift = 0 - var b = 0 - while ({ - b = bytes(pos) & 0xff - pos += 1 - result |= (b & 0x7f) << shift - shift += 7 - (b & 0x80) != 0 - }) () - result - } - val modules = scala.collection.mutable.ListBuffer.empty[Array[Byte]] - while (pos < bytes.length) { - val id = bytes(pos) & 0xff - pos += 1 - val size = leb() - if (id == 1) modules += java.util.Arrays.copyOfRange(bytes, pos, pos + size) // core module section - pos += size - } - // `wasm-tools component new` adds tiny `wit-component:shim` / `:fixups` modules for indirect - // lowering next to the linker's module; the linker's module is by far the largest. - require(modules.nonEmpty, s"no core module in $component") - IO.write(out, modules.maxBy(_.length)) - } - - private def wasmTools(log: Logger, args: String*): Unit = { - val cmd = "wasm-tools" +: args - log.info(s"Running: ${cmd.mkString(" ")}") - val exit = scala.sys.process.Process(cmd).!(scala.sys.process.ProcessLogger(log.info(_), log.warn(_))) - if (exit != 0) sys.error(s"wasm-tools failed ($exit): ${cmd.mkString(" ")}") - } - - // ----------------------------------------------------------------------------------------------- - // Upstream blocker: the linker synthesizes the core module's `component-type` section from IR - // signatures, where WIT type aliases are already dealiased (`use types.{metadata}` becomes an inline - // list>). The EXPORTED instance types then lack the alias type exports (e.g. - // `metadata`) that the dapr-wasm-components inbound provider imports, and `wac compose` fails - // ("instance is missing expected list export `metadata`"). Rebuild the component from the linked - // core module with the REAL world: extract the core module, strip the synthesized section, - // `wasm-tools component embed --encoding utf16` (scala-wasm lowers strings as UTF-16; the default - // utf8 garbles every string) and `wasm-tools component new`. (`withWitDirectory` / `withWitWorld` - // exist in WasmFeatures, but the 1.22.1-wasm.6 linker does not read them.) RETIRES when the linker - // keeps aliases (or reads the WIT world): use its component as is. - // ----------------------------------------------------------------------------------------------- - def buildComponent(log: Logger, linked: File, work: File, witDir: File, world: String, out: File): File = { - IO.createDirectory(work) - IO.createDirectory(out.getParentFile) - val core = work / "core.wasm" - val stripped = work / "core-stripped.wasm" - val embedded = work / "core-embedded.wasm" - extractMainCoreModule(linked, core) - wasmTools(log, "strip", "--delete", "component-type.*", core.getPath, "-o", stripped.getPath) - wasmTools( - log, - "component", - "embed", - "--encoding", - "utf16", - witDir.getPath, - "--world", - world, - stripped.getPath, - "-o", - embedded.getPath, - ) - wasmTools(log, "component", "new", embedded.getPath, "-o", out.getPath) - wasmTools(log, "validate", out.getPath) + def copyComponent(component: File, out: File): File = { + require(component.isFile, s"the linker wrote no $component") + IO.copyFile(component, out) out } } diff --git a/wiki/log.md b/wiki/log.md index 5ce0e22..e103b96 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -3,6 +3,9 @@ ## [2026-10-17] update | dapr-wasm-components 0.7.5 - Updated: scala-js/scala-wasm-component-spike.md — 0.7.3–0.7.5 leave the WIT and `wkg-config.toml` unchanged; 0.7.4 moves the OCI names, 0.7.5's compose.sh follows them (own provider pull dropped); dapr4s targets 0.7.5 +## [2026-10-16] update | wasm workarounds retired on a local scala-wasm build (#268/#269/#271) +- Updated: scala-js/scala-wasm-component-spike.md — new section: building scala-wasm locally; #268 retires the patched linker; #269 needs bindings that reference the alias (useWitAliases rewrite) to retire the re-embed; #271 leftovers (fork 2.13 scalalib, JUnit runtime); scala3#27300 not in a nightly yet; dapr-wasm-components v0.7.4 OCI rename + ## [2026-10-13] update | dapr-wasm-components 0.7.2: probe e2e asserts converse/schedule/timer results - Updated: scala-js/scala-wasm-component-spike.md — 0.7.2 fixed the three 0.7.1 provider gaps; echo conversation component; scheduler container next to a slim daprd; daprd 1.17.7 job GET encoding bug diff --git a/wiki/scala-js/scala-wasm-component-spike.md b/wiki/scala-js/scala-wasm-component-spike.md index beab14c..02781da 100644 --- a/wiki/scala-js/scala-wasm-component-spike.md +++ b/wiki/scala-js/scala-wasm-component-spike.md @@ -41,8 +41,8 @@ fixes (a local patch was used for one of them). | 1 | The nightly's Scala.js stdlib (`org.scala-js:scalajs-scalalib_2.13:`, built on Scala.js 1.20.2) uses JS: `BoxesRunTime.equals` → `(x === y)`, reachable from `Object.toString` | Overlay the `.sjsir` of the 4 files scala-wasm's Scala 3 fork patched (BoxesRunTime, Symbol, ArrayBuilder, Buffer) from `io.github.scala-wasm:scalajs-scalalib_2.13:3.8.3-RC1-wasm.4`, first on the linker classpath (first IR file per class wins) | A pure-Wasm-safe Scala 3 library-js, or a scala-wasm Scala 3 build per nightly | | 2 | Mainline Scala 3 has no WIT codegen. The nightly compiles the bindings (zero warnings under all dapr4s flags) but emits no WIT IR, and the linker writes an **empty output with `[success]`**. The fork compiler 3.8.3-RC1-wasm.4 is stale and rejects wit-bindgen-scala 0.1.0's output (96× "not compatible with the Component Model") | Bindings + `@WitExport` glue + import forwarders in **Scala 2.13 + plugin** projects. Scala 3 code depends on them | Scala 3 WIT support tracking scala-wasm (the scala-wasm docs: "No Scala 3" until upstreamed) | | 3 | A reactor has no `main`: module initializers run only behind a configured export, and `Reflect` is JS-backed | Link-time contract: the glue references a fixed-FQN object compiled against a `Provided` stub, and the app defines it. Missing = link error | — (design choice) | -| 4 | Linker: export params via linear memory (> 16 flat params) is `???`. The import side is a no-op `TODO`. Hits `on-topic-event` (22), `on-timer` (17), `jobs.schedule` (24), `converse` (28), `register-reminder` (18), `register-timer` (21) | Local linker 1.22.1-wasm.6-spike1 with a ~20-line export-side patch (load the param tuple with `genLoadMemory`, free it in post-return) | Both sides in scala-wasm | -| 5 | Linker synthesizes `component-type` from dealiased IR. Exported instances lack alias type exports (`metadata`), so `wac` fails with "instance is missing expected list export `metadata`". `withWitDirectory`/`withWitWorld` are ignored | Extract the core module, strip `component-type.*`, `wasm-tools component embed --encoding utf16 wit --world app`, `component new`. **utf16 is mandatory**: with utf8 `counter` arrives as `潣湵整r` | Linker should use the configured WIT world (or keep aliases) | +| 4 | Linker: export params via linear memory (> 16 flat params) is `???`. The import side is a no-op `TODO`. Hits `on-topic-event` (22), `on-timer` (17), `jobs.schedule` (24), `converse` (28), `register-reminder` (18), `register-timer` (21) | Local linker 1.22.1-wasm.6-spike1 with a ~20-line export-side patch (load the param tuple with `genLoadMemory`, free it in post-return) | Both sides in scala-wasm (done: #268; retired in dapr4s 2026-10-16, see below) | +| 5 | Linker synthesizes `component-type` from dealiased IR. Exported instances lack alias type exports (`metadata`), so `wac` fails with "instance is missing expected list export `metadata`". `withWitDirectory`/`withWitWorld` are ignored | Extract the core module, strip `component-type.*`, `wasm-tools component embed --encoding utf16 wit --world app`, `component new`. **utf16 is mandatory**: with utf8 `counter` arrives as `潣湵整r` | Linker should use the configured WIT world (or keep aliases) (done: #269 + an alias rewrite of the bindings; retired 2026-10-16, see below) | ## What dapr4s's shared code needs on a no-JS target @@ -170,6 +170,44 @@ are in AGENTS.md "Wasm component layer rules". - A published plugin can learn its own version from its jar manifest (`Implementation-Version`, written by sbt's default `packageOptions`), which `dapr4sWasmVersion` uses for the library dependencies. +## Retiring workarounds on an unreleased scala-wasm (2026-10-16) + +dapr4s pinned `1.22.1-wasm.7-dapr4s-local`: scala-wasm's `scala-wasm` branch at `32cda138` (it has +[#268](https://github.com/scala-wasm/scala-wasm/pull/268), params via memory) + a local merge of the open +[#269](https://github.com/scala-wasm/scala-wasm/pull/269) (aliases) and +[#271](https://github.com/scala-wasm/scala-wasm/pull/271) (exclusions), built by `scripts/wasm/build-scala-wasm.sh`. +What was learned: + +- **Building it**: `version` comes from `ir/.../ScalaJSVersions.scala` (`current`); a pre-release like + `1.22.1-wasm.7-dapr4s-local` passes its consistency check (patch > 0). The modules a fork user needs: + `ir`/`linkerInterface`/`linker`/`testAdapter`/`sbtPlugin` `2_12`, `javalib`, `javalibintf`, and for 2.13 + `library`, `scalalib`, `compiler`, `testInterface`, `testBridge`, `jUnitRuntime`, `jUnitPlugin`. Cold ~10 min. + Traps: `set every Compile / packageDoc / publishArtifact := false` turns off **every** `publishArtifact` (ivy files + published, no jars); and bash reads a script while it runs, so editing a running build script corrupts it. +- **#268 retires the patched linker**: the imports probe (`jobs.schedule`, `converse`, `register-reminder`/`-timer`) + links and runs against daprd; the exports with > 16 params link. +- **#269 alone does not retire the re-embed with wit-bindgen-scala 0.1.0 bindings**: the generator writes a `use`d + alias structurally (`type Metadata = Array[wit.Tuple2[String, String]]` in every `use`r), not as + `= types.Metadata`, so the linker's component still has no `metadata` export in the exported callbacks and `wac` + fails ("instance is missing expected list export `metadata`"). Rewriting those lines to `= types.Metadata` after + generation (dapr4s's `useWitAliases`) is enough: then `wasm-tools component wit` shows `use types.{metadata};` and + the linker's component composes AS IS. The linker already sets `string-encoding=utf16`, so the `--encoding utf16` + re-embed is not needed either. The linker still runs `wasm-tools component new` itself. +- **#271** removes the upstream `org.scala-js` library/javalib from Scala 3 classpaths. Two leftovers: (a) the + fork's 2.13 scalalib (`io.github.scala-wasm:scalajs-scalalib_2.13:2.13.18+`) still lands next to the nightly's + own scalalib (the PR says so), so dapr4s keeps that one exclusion; (b) it also excludes the upstream + `scalajs-junit-test-runtime_2.13` that munit_sjs1 needs and adds the fork's only with its JUnit plugin, so a munit + test link fails with "Referring to non-existent class org.junit.ComparisonFailure" until the fork's runtime is + added by hand. +- **scala/scala3#27300** (the unpickler crash that forces an empty javadoc jar) merged 2026-10-15 07:43; the newest + nightly then was `3.10.2-RC1-bin-20261015-88be2f5` (built 04:20, 11 commits behind the fix), so the workaround stays. +- **dapr-wasm-components v0.7.4** moved every OCI module to `ghcr.io/sideeffffect/dapr-wasm-components/` and + removed the flat `dapr-wasm-components-` packages; upstream `compose.sh` up to v0.7.4 still pulled the flat + names, so dapr4s pulled the providers itself. v0.7.5's `compose.sh` uses the new names: dapr4s calls it with + `--tag 0.7.5` again. +- Several agents share one machine: the e2e uses fixed ports and the container name `dapr4s-wasm-redis`, so two + concurrent runs break each other ("Port 3587 is not available", "No such container"). + ## See Also - [dapr-wasm-components WIT vs dapr4s](../dapr/dapr-wasm-components-vs-dapr4s.md)