Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,29 @@ jobs:
wasm/target/scala-wasm-linker
~/.cache/dapr4s/scala-wasm-src
key: scala-wasm-linker-${{ hashFiles('scripts/wasm/build-patched-linker.sh', 'scripts/wasm/patches/**') }}
# The UNRELEASED scala-java-time the wasm/ build pins (2.8.1-dapr4s-local: main with the merged
# https://github.com/cquiroz/scala-java-time/pull/618, tzdb compiled with a Scala 3 nightly; see
# scripts/wasm/build-scala-java-time.sh). Built from source with a JDK 17 and publishLocal'ed; cached
# by the script's hash (it pins the SHA, the compilers and the version). Goes away with a
# scala-java-time release > 2.8.0 built with Scala 3 >= 3.10.1.
# Both JDKs: the last one (25) stays the default; the script finds 17 through $JAVA_HOME_17_X64.
- name: Install JDK 17 next to 25 (scala-java-time's build)
if: matrix.platform == 'wasm'
uses: actions/setup-java@v5
with:
distribution: zulu
java-version: |
17
25
- name: Cache the scala-java-time source build
if: matrix.platform == 'wasm'
uses: actions/cache@v4
with:
path: ~/.ivy2/local/io.github.cquiroz
key: scala-java-time-src-${{ hashFiles('scripts/wasm/build-scala-java-time.sh') }}
- name: Build and publishLocal scala-java-time
if: matrix.platform == 'wasm'
run: scripts/wasm/build-scala-java-time.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)
Expand Down
31 changes: 29 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,10 +458,37 @@ agents push branches / open drafts with an EMPTY description; the user writes th
`PUBLISH_WASM_TO_CENTRAL: 'true'` (ON since 2026-10-13, approved by the user); 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=<version> 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.
- **java.time on the `wasm` path**: `dapr4s-wasm` depends on scala-java-time >= 2.8.0 (the default zone is
UTC without a JS host) and scala-java-locales >= 1.6.0 (`Locale.getDefault` no longer reads `window`). So
these link AND run (counter's `java-time` route, checked by `scripts/wasm/e2e.sh` and the consumer check):
`toString`/`parse` of the java.time types, the predefined `DateTimeFormatter`s (`ISO_*`, `RFC_1123_DATE_TIME`),
localized text (`getDisplayName(TextStyle, Locale)`), `ZoneOffset`s, `ZoneId.of("UTC")`, `ZoneId.systemDefault()`
(= UTC), `Duration`.
- **`DateTimeFormatter.ofPattern` and region zones** (`ZoneId.of("Europe/Prague")`, from `scala-java-time-tzdb`)
link AND run too, but only with an UNRELEASED scala-java-time: `scripts/wasm/build-scala-java-time.sh` builds it
from source and publishLocal's it as `2.8.1-dapr4s-local` (`scalaJavaTimeWasm` in `wasm/build.sbt`; CI runs the
script with a JDK 17 and caches `~/.ivy2/local/io.github.cquiroz` by its hash). Only the test components
(`counter`, the consumer check) depend on it; `dapr4s-wasm`'s POM stays on the released 2.8.0, because Central
must have every version it names. An app gets both by depending on that scala-java-time (+ `-tzdb`). The two
fixes:
- `ofPattern` (any pattern) kept zone-text printing reachable → `TimeZone.getTimeZone` → `Instant.now`.
https://github.com/cquiroz/scala-java-time/pull/618 (merged 2026-10-17, waits for a release > 2.8.0)
computes the raw offset lazily. The script builds the `main` commit that contains it.
- tzdb's provider has `@EnableReflectiveInstantiation`. Its registration is a static initializer (always
linked), and Scala 3 <= 3.10.0 emits it with the JS-backed `Reflect.registerInstantiatableClass(js.Array)`.
Scala 3 emits the Scala-typed `registerInstantiatableClassV2` since scala/scala3@3bb676972 (in 3.10.1, not
released yet), so the script compiles the tzdb module with a Scala 3 nightly.
Retires with a scala-java-time release > 2.8.0 (has #618) built with Scala 3 >= 3.10.1 (region zones need both): pin it in
`dapr4s-wasm`, drop `scalaJavaTimeWasm`, the script and its CI steps.
- **Shared code on the `wasm` path** must still avoid, because they fail to link:
- the wall-clock: `Instant.now`, `Clock.system*`, `System.currentTimeMillis` (scala-wasm's javalib calls
`js.Date.now`), and `TimeZone.getRawOffset` (it reads it). With the released scala-java-time 2.8.0 also
`DateTimeFormatter.ofPattern`/`appendPattern` and region zones (see above).
Retires when scala-wasm's javalib reads the clock through WASI (`wasi:clocks`).
- `println` (`console`) and `UUID.randomUUID` (no `SecureRandom`): they reach JS.
- Commands (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):
- `scripts/wasm/build-scala-java-time.sh` first (the unreleased scala-java-time; needs a JDK 17, idempotent)
- `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`
Expand Down
5 changes: 5 additions & 0 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,11 @@ plan and status are in `docs/WASM-COMPONENTS-PROPOSAL.md` §6. The rules are in
(`coreWasm`, `-Ddapr4s.wasm.core=source`) to test shared-code changes. The app
implements `dapr4s.wasm.DaprComponent` (`def app(using StateCapability): DaprApp`) and names it in the fixed-FQN
object `dapr4s.wasm.ComponentRoot`. A reactor component has no `main`, so the object is resolved at link time.
- **java.time.** `dapr4s-wasm` depends on scala-java-time 2.8.0 and scala-java-locales 1.6.0, the first versions
whose formatting/parsing path links without a JS host (default zone UTC, default locale `en`).
`DateTimeFormatter.ofPattern` and tzdb region zones need an unreleased scala-java-time (built from source for the
test components). The wall-clock (`Instant.now`, scala-wasm's javalib uses `js.Date.now`) does not link yet;
AGENTS.md lists the exact rule.
- **No connection config and no blocking bridge.** WIT imports are synchronous calls into the composed outbound
provider, which reads `DAPR_HTTP_PORT` itself.
- **The third wall** (`src/wasm/internal`) is the only place that sees WIT types. It maps WIT `result` variants into
Expand Down
32 changes: 24 additions & 8 deletions docs/WASM-COMPONENTS-PROPOSAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,10 @@ existing JVM example (state + pub/sub + invoke + an actor) so the three platform
- **Compiler/IR match**: dapr4s needs a Scala 3.9 nightly. The fork's linker must read that compiler's Scala.js
IR. Phase 0 answers this.
- **Shared-code deps without JS**: `scala-java-time` (tzdb) and the `JsonCodec` instances must link without
`@JSImport`. Unknown until Phase 0.
`@JSImport`. Phase 0 answered it (§6, item 2); java.time formatting/parsing works since 2026-10-17, and
`DateTimeFormatter.ofPattern` + tzdb region zones with an unreleased scala-java-time built from source (pending
a release > 2.8.0, which has the merged [cquiroz/scala-java-time#618](https://github.com/cquiroz/scala-java-time/pull/618);
region zones also need it built with Scala 3 >= 3.10.1). The wall-clock does not (scala-wasm's javalib).
- **Hosts**: GC + EH components need Spin canary or recent wasmtime. The gRPC provider is Spin-only.
- **Fork churn**: the scala-wasm API "is subject to change without notice". Pin versions.

Expand Down Expand Up @@ -149,11 +152,22 @@ Answers to the risk questions (section 5):
reports success). scala-wasm's only Scala 3 build (`3.8.3-RC1-wasm.4`) is stale and rejects the current
generator's output.
2. **Shared code without JS: yes, for what dapr4s itself uses.** Opaque types, `JsonCodec`, the capability tiers,
`DaprApp`/`InvokeRoute`/validation and the exceptions link with zero JS. Excluded: `java.time`
formatting/parsing (`Instant.parse`/`toString`, so also `WorkflowSnapshot.toString`): scala-java-time →
`Locale.getDefault` → scala-java-locales `DefaultLocale` → `window`. Also `Instant.now`/`currentTimeMillis`,
`println` and `UUID.randomUUID` (no `SecureRandom`). `java.time.Duration`, `FiniteDuration`, `UUID.fromString`
and `String.format` link. The published POM also brings mainline `scalajs-library`/`javalib` 1.22.0; the `wasm`
`DaprApp`/`InvokeRoute`/validation and the exceptions link with zero JS. `java.time` formatting/parsing
(`Instant.parse`/`toString`, so also `WorkflowSnapshot.toString`) was excluded in Phase 0 (scala-java-time →
`Locale.getDefault` → scala-java-locales `DefaultLocale` → `window`) and **works since 2026-10-17**: scala-java-locales
1.6.0 ([cquiroz/scala-java-locales#640](https://github.com/cquiroz/scala-java-locales/pull/640)) reads `window`
only with a JS host, scala-java-time 2.8.0 ([cquiroz/scala-java-time#616](https://github.com/cquiroz/scala-java-time/pull/616))
defaults to UTC without one; `dapr4s-wasm` depends on both and counter's `java-time` route runs it under daprd.
`DateTimeFormatter.ofPattern` (any pattern reached `Instant.now` through zone-text printing → `TimeZone`) and region
zones from `scala-java-time-tzdb` ([cquiroz/scala-java-time#617](https://github.com/cquiroz/scala-java-time/pull/617)
ships portable data, but Scala 3 <= 3.10.0 emits the provider's `@EnableReflectiveInstantiation` registration with
`js.Array`s) **work since 2026-10-17 with an unreleased scala-java-time**: `scripts/wasm/build-scala-java-time.sh`
builds `main` with the merged [cquiroz/scala-java-time#618](https://github.com/cquiroz/scala-java-time/pull/618) (lazy
`TimeZone` raw offset), and the tzdb module with a Scala 3 nightly that has scala/scala3@3bb676972 (Scala-typed
`registerInstantiatableClassV2`, in 3.10.1). Counter's `java-time` route converts to `Europe/Prague` and formats
and parses with `ofPattern` under daprd. Still excluded: the wall-clock (`Instant.now`/`Clock`/`currentTimeMillis`
→ scala-wasm javalib `js.Date.now`), `println` and `UUID.randomUUID` (no `SecureRandom`). `java.time.Duration`,
`FiniteDuration`, `UUID.fromString` and `String.format` link. The published POM also brings mainline `scalajs-library`/`javalib` 1.22.0; the `wasm`
build must exclude them.
3. **Bindings under the CC flags: they compile with zero warnings under all six flags, but that does not help,
because the nightly ignores WIT.** They must be compiled by Scala 2.13 + the scala-wasm plugin. This is the same
Expand Down Expand Up @@ -182,8 +196,10 @@ with wasmtime's cache. Warm round trip through the sidecar 2.4 ms median, incl.
- Upstream (scala-wasm): params via linear memory on both sides; use the configured WIT world, or keep aliases,
in the component type; a Scala 3 WIT backend that tracks the library (or upstreaming to Scala.js). Until then:
carry the linker patch and the re-embed step, and keep the stdlib overlay per nightly bump.
- dapr4s shared code must avoid `java.time` formatting and wall-clock/random APIs on the `wasm` path (or provide
WASI-backed replacements).
- dapr4s shared code must avoid wall-clock/random APIs on the `wasm` path (or provide WASI-backed replacements), and
`DateTimeFormatter.ofPattern` and tzdb region zones until a scala-java-time release > 2.8.0 (has #618; region zones: built with
Scala 3 >= 3.10.1) exists (an app can use them now with the source build). java.time parsing, the predefined formatters,
localized text and fixed-offset zones work (scala-java-locales 1.6.0 + scala-java-time 2.8.0).
- Also found: `compose.sh` exits 1 on success (`tmp` assigned in a `$(...)` subshell), and the wasi-http
provider's `store-not-found` payload is daprd's raw body, not the store name.
Both are fixed upstream (0.7.1: `compose.sh`; 0.7.2: setup-error payloads are daprd's message). 0.7.2 also
Expand Down
74 changes: 74 additions & 0 deletions scripts/wasm/build-scala-java-time.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Build an UNRELEASED scala-java-time from source and publishLocal it (to ~/.ivy2/local) as version
# $SJT_VERSION: scala-java-time_sjs1_3 and scala-java-time-tzdb_sjs1_3. The wasm/ build (build.sbt,
# `scalaJavaTimeWasm`) uses exactly this version. Nothing else of scala-java-time is built.
#
# WHAT: cquiroz/scala-java-time `main` at $BASE, the merge commit of
# - https://github.com/cquiroz/scala-java-time/pull/618 (merged 2026-10-17): `TimeZone.getTimeZone`
# computes the raw offset lazily, so `DateTimeFormatter.ofPattern` no longer links `Instant.now` (the
# wall clock, absent without a JS host).
# with the tzdb module compiled with a Scala 3 NIGHTLY ($TZDB_SCALA) instead of the build's 3.9.0:
# - `scala-java-time-tzdb`'s provider has `@EnableReflectiveInstantiation`. Its registration runs in a
# static initializer, which the linker always keeps. Scala 3 <= 3.10.0 emits it with the JS-backed
# `Reflect.registerInstantiatableClass(..., js.Array(...))`, which does not link without a JS host.
# Scala 3 emits the Scala-typed `registerInstantiatableClassV2` since
# https://github.com/scala/scala3/commit/3bb676972 (on the release-3.10.1 branch, not released yet).
# Core stays on the build's Scala 3 (3.9.0); only the tzdb module needs the newer compiler.
# - The nightly would also become the tzdb POM's scala3-library version, and that would evict the
# scala3-library of every app that depends on it. So the script rewrites that one version in the
# published POM and ivy.xml back to core's. WHY SAFE: the tzdb module's code needs nothing newer
# from the library (it compiles against 3.9.0 core and is unchanged); only the codegen differs.
# WHY: neither fix is in a scala-java-time release yet (newest on Central: 2.8.0): #618 waits for a release
# > 2.8.0, and region zones also need that release built with Scala 3 >= 3.10.1.
# RETIRES when such a release exists: pin it in wasm/build.sbt (`scalaJavaTimeWasm`), and delete this
# script and its CI steps.
#
# Idempotent: skips the build when the stamp (SHA + compilers + version) matches (FORCE=1 rebuilds).
# The CI wasm leg caches ~/.ivy2/local/io.github.cquiroz keyed by this file's hash.
# Needs a JDK 17 for scala-java-time's build (JDK 21+ fails on its fatal deprecation warnings):
# $SJT_JAVA_HOME, else $JAVA_HOME_17_X64 (set by actions/setup-java), else /usr/lib/jvm/java-17-openjdk-amd64.
set -euo pipefail
SJT_VERSION="${SJT_VERSION:-2.8.1-dapr4s-local}"
BASE=48cf7a60dad673b7fca510f77f62d89005049a05 # main, "Merge pull request #618"
CORE_SCALA=3.9.0
TZDB_SCALA=3.10.2-RC1-bin-20261017-ae374e9-NIGHTLY
ivy="${IVY_LOCAL:-$HOME/.ivy2/local}"
org="$ivy/io.github.cquiroz"
stampFile="$org/dapr4s-build-stamp-$SJT_VERSION"
stamp="$BASE $CORE_SCALA $TZDB_SCALA"
tzdbDir="$org/scala-java-time-tzdb_sjs1_3/$SJT_VERSION"
if [[ -f "$stampFile" && "$(cat "$stampFile")" == "$stamp" && -s "$tzdbDir/jars/scala-java-time-tzdb_sjs1_3.jar" &&
"${FORCE:-0}" != 1 ]]; then
echo "build-scala-java-time.sh: $SJT_VERSION up to date in $ivy (FORCE=1 to rebuild)"
exit 0
fi
jdk="${SJT_JAVA_HOME:-${JAVA_HOME_17_X64:-/usr/lib/jvm/java-17-openjdk-amd64}}"
[[ -x "$jdk/bin/java" ]] || { echo "build-scala-java-time.sh: no JDK 17 at $jdk (set SJT_JAVA_HOME)" >&2; exit 1; }
work="${WORK:-${XDG_CACHE_HOME:-$HOME/.cache}/dapr4s/scala-java-time-src}"
if [[ ! -d "$work/.git" ]]; then
git clone --filter=blob:none --no-checkout https://github.com/cquiroz/scala-java-time.git "$work"
fi
cd "$work"
git fetch -q origin "$BASE"
git checkout -q -f --detach "$BASE"
git clean -q -fdx -e target
# `set` per project, not `++`: `++` is sticky and would override the tzdb module's compiler again.
JAVA_HOME="$jdk" PATH="$jdk/bin:$PATH" sbt -batch -no-colors --java-home "$jdk" \
"set ThisBuild / version := \"$SJT_VERSION\"" \
'set ThisBuild / resolvers += ("scala3-nightlies" at "https://repo.scala-lang.org/artifactory/maven-nightlies")' \
"set core.js / scalaVersion := \"$CORE_SCALA\"" \
"set tzdb.js / scalaVersion := \"$TZDB_SCALA\"" \
'set ThisBuild / packageDoc / publishArtifact := false' \
coreJS/publishLocal tzdbJS/publishLocal
for f in "$tzdbDir"/poms/*.pom "$tzdbDir"/ivys/ivy.xml; do
sed -i "s/$TZDB_SCALA/$CORE_SCALA/g" "$f"
rm -f "$f".md5 "$f".sha1
done
if grep -rq NIGHTLY "$tzdbDir"/poms "$tzdbDir"/ivys; then
echo "build-scala-java-time.sh: the tzdb POM still names the nightly" >&2
exit 1
fi
[[ -s "$tzdbDir/jars/scala-java-time-tzdb_sjs1_3.jar" ]] ||
{ echo "build-scala-java-time.sh: scala-java-time-tzdb was not published" >&2; exit 1; }
echo "$stamp" >"$stampFile"
echo "build-scala-java-time.sh: published scala-java-time $SJT_VERSION ($stamp) to $ivy"
11 changes: 8 additions & 3 deletions scripts/wasm/consumer-check.sh
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
#!/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
# runs the counter part of scripts/wasm/e2e.sh on the result.
# one dapr4s libraryDependency (`dapr4s-wasm`), the app's own WIT world + wkg files, and the fixed-name ComponentRoot
# — no in-build workaround. Plus the app's own scala-java-time + scala-java-time-tzdb (`ofPattern`, region zones).
# Everything comes from `cd wasm && sbt publishLocal` (run it first), scripts/wasm/build-scala-java-time.sh and
# Maven Central. Then runs the counter part of scripts/wasm/e2e.sh on the result.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
version="${DAPR4S_WASM_VERSION:-$(cd "$repo/wasm" && sbt -batch -no-colors 'print wallWasm/version' | tail -1 | tr -d '[:space:]')}"
nightly="$(sed -n 's/^val scala3Nightly = "\(.*\)"$/\1/p' "$repo/build.sbt")"
sjt="$(sed -n 's/^val scalaJavaTimeWasm = "\(.*\)"$/\1/p' "$repo/wasm/build.sbt")"
dir="$repo/wasm/target/consumer-check"
rm -rf "$dir"
mkdir -p "$dir/project" "$dir/wit" "$dir/src"
Expand All @@ -31,6 +33,9 @@ lazy val counter = project
"-Yexplicit-nulls", "-experimental", "-Wconf:any:error"),
dapr4sWasmComponentsDirectory := file("$repo/wasm/target/components"),
libraryDependencies += "com.github.sideeffffect" %%% "dapr4s-wasm" % dapr4sWasmVersion,
// The app's own choice: a scala-java-time with ofPattern on Wasm, and the tz database (as on JS).
libraryDependencies += "io.github.cquiroz" %%% "scala-java-time" % "$sjt",
libraryDependencies += "io.github.cquiroz" %%% "scala-java-time-tzdb" % "$sjt",
)
SBT
echo "consumer-check: building test/wasm/counter against the published dapr4s wasm artifacts $version"
Expand Down
Loading
Loading