diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7fdda6..0e11589 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,40 +30,80 @@ jobs: exit 1 fi + # One matrix leg per platform. Adding Scala Native one day should be a new include + # entry (platform: native, platform-flags: '--native', ...), not a new job. + # + # Platform-specific dependencies need no flags here: jvm-deps.scala / + # jvm-test-deps.test.scala / js-deps.scala scope their `using dep` directives via + # `//> using target.platform`, so `scala-cli ... --js .` simply never resolves the + # Dapr Java SDK or testcontainers, and JVM invocations never resolve the + # ScalablyTyped facades. test: + strategy: + fail-fast: false + matrix: + include: + - platform: jvm + platform-flags: '' + # Integration suite: a real daprd sidecar (and Redis) per test via + # testcontainers-dapr; Docker is preinstalled on ubuntu-latest. + unit-test-args: '' + integration-command: scala-cli test . --test-only 'dapr4s.test.integration.*' + - platform: js + platform-flags: '--js' + # The Wasm-only integration suites use orphan js.await, which the plain-JS + # linker cannot handle (it wedges instead of erroring) — exclude them from + # the plain-backend unit-test leg; the integration command runs them on the + # Wasm backend instead. + unit-test-args: --exclude test/js/integration + # Brings up redis + placement + scheduler + daprd + the Wasm-packaged test + # server, runs the munit suites on Wasm+JSPI (Node 25), tears down. + integration-command: scripts/test-js-integration.sh + name: test (${{ matrix.platform }}) runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 with: fetch-depth: 0 - uses: coursier/cache-action@v8 + - uses: coursier/setup-action@v1 + if: matrix.platform == 'js' - uses: VirtusLab/scala-cli-setup@v1 with: power: true - - name: Compile - run: scala-cli compile . - - name: Unit tests - run: scala-cli test . --test-only 'dapr4s.test.unit.*' - - integration-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 + # JSPI is on by default from Node 25; the runner's default Node has no JSPI and + # scala-cli's runner passes no V8 flags, so the Wasm integration tests need this. + - name: Set up Node 25 + if: matrix.platform == 'js' + uses: actions/setup-node@v4 with: - fetch-depth: 0 - - uses: coursier/cache-action@v8 - - uses: VirtusLab/scala-cli-setup@v1 + node-version: 25 + - name: Install npm dependencies (@dapr/dapr) + if: matrix.platform == 'js' + run: npm ci + # The ScalablyTyped-generated facades (js-deps.scala) live in ~/.ivy2/local; the + # generation script no-ops when the pinned digests are already there. Keep the + # cache key in sync with the converter version + flags in the script. + - name: Cache ScalablyTyped facades + if: matrix.platform == 'js' + uses: actions/cache@v4 with: - power: true - # The integration suite spins up a real daprd sidecar (and Redis) per test via - # testcontainers-dapr; Docker is preinstalled on ubuntu-latest. These are the - # round-trip publish/subscribe + service-invocation tests — the coverage that - # would have caught the JSON double-encoding regression that compile-only CI missed. + path: | + ~/.ivy2/local/org.scalablytyped + ~/.cache/scalablytyped + key: scalablytyped-${{ hashFiles('package-lock.json', 'scripts/generate-st-facades.sh') }} + - name: Generate ScalablyTyped facades + if: matrix.platform == 'js' + run: scripts/generate-st-facades.sh + - name: Compile + run: scala-cli compile ${{ matrix.platform-flags }} . + - name: Unit tests + run: scala-cli test ${{ matrix.platform-flags }} . ${{ matrix.unit-test-args }} --test-only 'dapr4s.test.unit.*' - name: Integration tests - run: scala-cli test . --test-only 'dapr4s.test.integration.*' + run: ${{ matrix.integration-command }} publish: - needs: [format, test, integration-test] + needs: [format, test] if: github.event_name == 'push' runs-on: ubuntu-latest steps: @@ -71,13 +111,44 @@ jobs: with: fetch-depth: 0 - uses: coursier/cache-action@v8 + - uses: coursier/setup-action@v1 - uses: VirtusLab/scala-cli-setup@v1 with: power: true - - name: Publish + # The Scala.js publish compiles against the ScalablyTyped facade deps + # (compileOnly in js-deps.scala), so the artifacts must exist in ~/.ivy2/local + # here too — generation is a BUILD-time requirement only; consumers of the + # published artifact never need it (see the embed step below). + - name: Install npm dependencies (@dapr/dapr) + run: npm ci + - name: Cache ScalablyTyped facades + uses: actions/cache@v4 + with: + path: | + ~/.ivy2/local/org.scalablytyped + ~/.cache/scalablytyped + key: scalablytyped-${{ hashFiles('package-lock.json', 'scripts/generate-st-facades.sh') }} + - name: Generate ScalablyTyped facades + run: scripts/generate-st-facades.sh + - name: Publish (JVM) run: scala-cli publish . env: PUBLISH_USER: ${{ secrets.PUBLISH_USER }} PUBLISH_PASSWORD: ${{ secrets.PUBLISH_PASSWORD }} PUBLISH_SECRET_KEY: ${{ secrets.PUBLISH_SECRET_KEY }} PUBLISH_SECRET_KEY_PASSWORD: ${{ secrets.PUBLISH_SECRET_KEY_PASSWORD }} + # Publishes a SELF-CONTAINED dapr4s_sjs1_3: the ScalablyTyped facade classes + # (renamed to the dapr4styped.* package) are embedded into the jar via + # --resource-dirs, and the facade deps are compileOnly so the POM references + # Maven-Central artifacts only — downstream Scala.js users resolve dapr4s like + # any ordinary dependency, no generation step; see js-deps.scala and + # scripts/embed-st-facades.sh. + - name: Stage ScalablyTyped facade classes for embedding + run: scripts/embed-st-facades.sh + - name: Publish (Scala.js) + run: scala-cli publish --js . --resource-dirs .scala-build/st-embed + env: + PUBLISH_USER: ${{ secrets.PUBLISH_USER }} + PUBLISH_PASSWORD: ${{ secrets.PUBLISH_PASSWORD }} + PUBLISH_SECRET_KEY: ${{ secrets.PUBLISH_SECRET_KEY }} + PUBLISH_SECRET_KEY_PASSWORD: ${{ secrets.PUBLISH_SECRET_KEY_PASSWORD }} diff --git a/.gitignore b/.gitignore index 9f72758..97aac30 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ .claude - +node_modules/ +# Scratch tree of the ScalablyTyped converter (scripts/generate-st-facades.sh); the script +# removes it after every run, ignored here as a second line of defence — scala-cli would +# otherwise compile it as project sources. +out/ diff --git a/.scalafmt.conf b/.scalafmt.conf index 6f1c9a9..6525c8a 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -10,18 +10,23 @@ trailingCommas = always # Files using experimental capture-checking `^{...}` return-type annotations in a # position scalafmt's parser cannot yet handle are excluded until scalafmt gains # nightly-Scala CC syntax support. (Files that merely *use* `^{...}` but still parse -# — e.g. src/derivation/WorkflowEvents.scala, test/unit/CCTest.scala — stay checked.) +# — e.g. src/shared/derivation/WorkflowEvents.scala, test/shared/unit/CCTest.scala — stay checked.) project.excludeFilters = [ - "src/Capabilities\\.scala", - "src/DaprCapability\\.scala", - "src/Workflows\\.scala", - "src/derivation/Forwarders\\.scala", - "src/derivation/WorkflowActivityCalls\\.scala", - "src/internal/ConfigurationCapabilityImpl\\.scala", - "src/internal/DaprCapabilityImpl\\.scala", - "src/internal/WorkflowContextImpl\\.scala", - "test/integration/apps/WorkflowApp\\.scala", - "test/unit/CapabilityDerivationFixtures\\.scala", - "test/unit/WorkflowActivityDerivationFixtures\\.scala", - "test/unit/WorkflowEventsTest\\.scala" + "src/shared/Capabilities\\.scala", + "src/shared/DaprCapability\\.scala", + "src/shared/Workflows\\.scala", + "src/shared/derivation/Forwarders\\.scala", + "src/shared/derivation/WorkflowActivityCalls\\.scala", + "src/jvm/DaprCapabilityPlatform\\.scala", + "src/jvm/internal/ConfigurationCapabilityImpl\\.scala", + "src/jvm/internal/DaprCapabilityImpl\\.scala", + "src/jvm/internal/WorkflowContextImpl\\.scala", + "src/js/internal/ConfigurationCapabilityImpl\\.scala", + "src/js/internal/DaprAppServer\\.scala", + "src/js/internal/DaprCapabilityImpl\\.scala", + "src/js/internal/WorkflowContextImpl\\.scala", + "test/shared/apps/WorkflowApp\\.scala", + "test/shared/unit/CapabilityDerivationFixtures\\.scala", + "test/shared/unit/WorkflowActivityDerivationFixtures\\.scala", + "test/shared/unit/WorkflowEventsTest\\.scala" ] diff --git a/AGENTS.md b/AGENTS.md index 5b335d5..b41d415 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,8 +23,29 @@ Both must stay in sync with the code at all times. - **Scala version**: latest nightly (`3.9.0-RC1-bin-*-NIGHTLY` or later). Always use the newest available nightly to get the latest capture-checking and safe-mode fixes. Update when the build tool hints that a newer nightly is available. -- **Build tool**: Scala CLI (`project.scala` using directives). Run tests with - `scala-cli test . --test-only "*unit*"` for unit tests. +- **Platforms**: JVM **and Scala.js** (`//> using platform "jvm" "scala-js"`; jvm first = default). + Plain `scala-cli compile/test .` builds the JVM platform; Scala.js invocations just add `--js` + (JVM-only deps live in `jvm-deps.scala`/`jvm-test-deps.test.scala`, JS-only deps in + `js-deps.scala` — each scoped to its platform by a `target.platform` directive, which is what + keeps the `_sjs1_3` build/POM clean; no `--exclude` flags are needed for dependency scoping): + - `scala-cli compile --js .` + - `scala-cli test --js . --exclude test/js/integration --test-only 'dapr4s.test.unit.*'` + (That `--exclude` is the only one in the build — the Wasm-only integration suites contain + orphan `js.await`, which *wedges* the plain-JS linker instead of erroring; see Testing.) +- **Directory layout**: `src/{shared,jvm,js}` and `test/{shared,jvm,js}`. The layout is for + humans — scala-cli has no platform directory convention, so every file under a `jvm/`/`js/` + directory ALSO carries its own per-file `//> using target.platform "jvm"`/`"scala-js"` + directive (the directive is what scopes it). Packages are unchanged by the layout + (`src/jvm/internal/` and `src/js/internal/` are both `dapr4s.internal`). + `//> using jsEsVersionStr "es2017"` is required by `js.async`/`js.await`. + **JS build prerequisite**: the ScalablyTyped facade jars referenced by `js-deps.scala` live only + in the local ivy repository — run `scripts/generate-st-facades.sh` once per machine (and again + whenever the pinned digests change) before the first `--js` build, or resolution fails. This is + a requirement for building dapr4s only: the published `dapr4s_sjs1_3` jar embeds the facade + classes (`scripts/embed-st-facades.sh`), so consumers resolve everything from Maven Central. +- **Build tool**: Scala CLI (`project.scala` using directives). **scala-cli >= 1.13.0 is required + for the Scala.js build** (munit 1.3.0 JS needs Scala.js IR 1.21; the JS integration harness + wants >= 1.14). Run unit tests with `scala-cli test . --test-only 'dapr4s.test.unit.*'`. - **JVM**: Zulu 25 (`//> using jvm "zulu:25.0.3"` or later). JDK 25 is required for stable virtual thread support (no carrier pinning on `synchronized`). - **Compiler flags** (all active): @@ -34,8 +55,15 @@ Both must stay in sync with the code at all times. - `-experimental` — enables clause interleaving (`def f[A](x: A)[B: TC]: B`). - `-Ycc-verbose`, `-Yexplicit-nulls`, `-Wconf:any:error` (fatal warnings). - **Dependencies**: upickle 3.3.1 (pinned — 4.x crashes on CC-annotated types, test-only), - munit 1.3.0, testcontainers-scala-munit 0.43.6, testcontainers-dapr 1.17.2. (upickle, munit, and - both testcontainers deps are `test.dep` only — they are not part of the published library.) + munit 1.3.0, testcontainers-scala-munit 0.43.6, testcontainers-dapr 1.17.2 (all test-only — + not part of the published library). Cross deps use the `::version` (double-colon) form; + `scala-java-time` provides `java.time` on Scala.js (a thin JDK shim on the JVM). Platform + scoping: the Dapr Java SDK lives in `jvm-deps.scala`; testcontainers in + `jvm-test-deps.test.scala` (plain `using dep` + the `.test.scala` suffix for test scope — + deliberately NOT `test.dep`, which is not platform-scoped and would leak into the JS test + build); the ScalablyTyped facade coordinates in `js-deps.scala`. The JS layer's npm deps + (`@dapr/dapr`, `@types/express`, `@types/node`, `typescript`) are declared in `package.json` + (with `package-lock.json` committed — the ScalablyTyped digests are deterministic in it). --- @@ -158,11 +186,107 @@ Specific rules currently active in this codebase: - **Store names**: `StateStoreName` (`DaprCapability.state`) vs. `LockStoreName` (`DaprCapability.lock`) — distinct Dapr components with distinct YAML. (Mirrors the existing `ConfigurationStoreName`/`SecretStoreName` split.) - **State keys**: `StateStoreKey` (app-level `StateCapability`) vs. `ActorStateKey` (per-instance `ActorContext`/`ActorState`). -### Java interop boundary -Everything in `src/internal/` is marked `@scala.caps.assumeSafe`. This is the only place Java -SDK types may appear. Nothing from the Java SDK (`Mono`, `DaprClient`, `GrpcChannel`, proto -classes, etc.) may appear in `src/` files outside `internal/` or in any test file. The -`@assumeSafe` boundary is the wall. +### SDK interop boundary (Java on JVM, @dapr/dapr on JS) +Everything in the two `internal/` trees is marked `@scala.caps.assumeSafe`. There are two +platform walls behind the same boundary: + +- `src/jvm/internal/` (all jvm-tagged) is the **JVM wall**: the only place Java SDK types may + appear. Nothing from the Java SDK (`Mono`, `DaprClient`, `GrpcChannel`, proto classes, etc.) + may appear in `src/` files outside it or in any test file. +- `src/js/internal/` (all js-tagged, same package `dapr4s.internal`) is the **JS wall**: the only + place `@dapr/dapr` (and express/Node) types may appear. Those types are the + **ScalablyTyped-generated facades** (`dapr4styped.daprDapr`, `dapr4styped.expressServeStaticCore`, + `dapr4styped.node`, ... — generated into the dapr4s-specific `dapr4styped.*` root package, see + js-deps.scala), plus the single surviving hand-written shim in + `dapr4s.internal.facade` (`src/js/internal/facade/ExpressModule.scala`). No `js.Promise`, + `dapr4styped.*` type, or other JS interop type may leak into the public API. Two deliberate + carve-outs mirror the JVM side (where `src/jvm/Dapr.scala` constructs Java SDK clients): the + platform `Dapr` entry points (`src/jvm/Dapr.scala`, `src/js/Dapr.scala`) may construct SDK + clients to hand to the internal layer, and the JS-only `runAsync`/`serveAsync` conveniences + intentionally return `js.Promise` at the program edge — no other public member may. + +The `@assumeSafe` boundary is the wall on both platforms — same rule, same documentation duty +(WHAT/WHY/WHY SAFE on every escape hatch). + +### Platform-diverging public surface: the platform-trait technique + +When a building block exists in only one platform's SDK, **never** stub it with +`UnsupportedOperationException` — make it not exist on the other platform at compile time. +THE pattern (jobs/conversation are the worked example): + +- The shared trait inherits a platform parent trait: + `trait DaprCapability extends ..., DaprCapabilityPlatform` and + `object DaprCapability extends DaprCapabilityCompanionPlatform` (in `src/shared/`). +- `src/jvm/DaprCapabilityPlatform.scala` contributes the JVM-only members (`jobs`, + `conversation` + the companion transformer twins); the `src/js/` twin declares the same + trait names but **empty**. WHY traits and not a per-platform `DaprCapability` file: the + companion must sit in the same file as the trait, so the trait/companion pair cannot be + forked per platform without duplicating the whole shared surface. +- Everything reachable only from that surface moves wholly under `src/jvm/`: + `JobsCapability`/`ConversationCapability`, their models (`JobsModels.scala`, + `ConversationModels.scala`), their impls, the `Jobs.derive` engine + (`src/jvm/derivation/Jobs.scala`), and the jobs runtime forwarders + (`Forwarders extends ForwardersPlatform`; `Forwarders.jobRoute` stays shared because the + inbound job-trigger side is cross-platform). +- On the JVM platform trait, keep the `^{this}` return types working via a self-type + (`this: DaprCapability =>`) so CC tracks the same capability instance as the shared trait. + +Result: `DaprCapability.jobs` on Scala.js is "value jobs is not a member" at compile time, and +the `_sjs1_3` artifact carries no jobs/conversation API at all. + +### Scala.js layer rules + +- **Orphan `js.await` ONLY via `JsAwait`** (`src/js/internal/JsAwait.scala`) — the single home of + the `scala.scalajs.js.wasm.JSPI.allowOrphanJSAwait` import. Never import it anywhere else. +- **Every callback invoked from a JS frame re-enters `js.async`** before touching dapr4s code + (express handlers, SDK activity executors, promise reactions). JSPI suspension cannot cross a + JavaScript stack frame — skipping this throws `WebAssembly.SuspendError` at runtime. +- **Facades are ScalablyTyped-generated**, not hand-written. `scripts/generate-st-facades.sh` + converts the npm packages pinned in `package.json` into `dapr4styped.*` jars in `~/.ivy2/local` + (run it once per machine; idempotent, fast skip when the jars exist). Facade imports use the + **`dapr4styped.*` root package** (`--outputPackage dapr4styped`), never ST's default + `typings.*` — the classes ship inside the published jar and must not collide with a consumer's + own ST generation. `js-deps.scala` pins the resulting + `org.scalablytyped::::-` coordinates as **`compileOnly.dep`** (they + are on the compile classpath and platform-scoped like `using dep`, but never enter the + published POM); the digests are deterministic in (package-lock.json, converter version, + converter flags), and the script and `js-deps.scala` must name the same digests — the script + fails loudly on drift. The converter tuple (version `1.0.0-beta45`, + `--scala 3.3.6 --scalajs 1.21.0 -s es2022 --outputPackage dapr4styped`) is THE pin: changing + any element changes the digests. The ST jars are precompiled with their own flags, so our + `-Wconf:any:error`/CC flags do not apply to them — but OUR code must still compile with zero + warnings, and explicit-nulls means ST results must NOT be `.nn`-ed (it is an error: unnecessary + `.nn`). +- **The published `dapr4s_sjs1_3` jar is self-contained**: at publish time + `scripts/embed-st-facades.sh` resolves the exact transitive `org.scalablytyped` jar set of the + three facade roots (via coursier, from the js-deps.scala pins — never by globbing the ivy + directory, which accumulates stale digests) and stages their class/tasty/sjsir entries in + `.scala-build/st-embed`; the JS publish then runs with `--resource-dirs .scala-build/st-embed`. + Consumers resolve dapr4s from Maven Central alone (the POM carries scalablytyped-runtime + + scalajs-dom as regular deps for the embedded facades). Generation remains a prerequisite for + BUILDING dapr4s itself only. +- **The one hand-written exception**: `src/js/internal/facade/ExpressModule.scala`, the express + default-import shim (ST's namespace-import entry point is not callable under Node ESM, and + `express.text` lost its type to a converter warning). Anything else ST genuinely cannot express + must live there too, justified in its header; everything else uses `dapr4styped.*` directly. +- **The ST types ARE the signatures; verify runtime behaviour against `node_modules` sources.** + TypeScript types are erased — and occasionally wrong (`SubscribeConfigurationStream.stop()` + returns a Promise despite `: void`; transaction etags go on the wire as plain strings despite + `IEtag = {value}`) — so behavioural claims (soft-failure shapes, falsy-body bugs, enum wire + values) still come from reading the installed `@dapr/dapr`/`express` JS sources (record findings + in `wiki/dapr/dapr-js-sdk.md`). Where the ST type and the verified runtime diverge, keep the + runtime behaviour and document the divergence at the cast (WHAT/WHY/WHY SAFE). +- **Never reference a deep-module ST object in value position for `@dapr/dapr`.** ScalablyTyped's + deep-module specifiers (e.g. `@dapr/dapr/enum/HttpMethod.enum`) carry no `.js` extension and the + package has no `exports` map, so Node ESM (the Wasm/JSPI production target) throws + `ERR_MODULE_NOT_FOUND` at load time. Deep **types** are fine (erased, no import emitted); for + **values** use the `dapr4styped.daprDapr.mod.*` root re-exports, and where none exists (e.g. + `LockStatus`) pin the runtime values with a documented source reference. Runtime-verified by the + e2e smoke run — compile-green is not enough to catch this. +- SDK gotchas (still true under ST): **ports are strings** everywhere in the JS SDK; + **`CommunicationProtocolEnum` is numeric with `GRPC = 0`, `HTTP = 1`** — defaulting to 0 + silently picks gRPC. `HttpMethod` values are lowercase strings. Options objects are + `Partial<...>` — ST renders them as builder-style traits (`PartialDaprClientOptions().setX(...)`). --- @@ -180,7 +304,8 @@ blocks the build (and `publish`) on any unformatted file, so a missed format fai Some files are intentionally excluded from formatting via `project.excludeFilters` in `.scalafmt.conf`: they use experimental capture-checking `^{...}` return-type annotations in a position scalafmt's parser does not yet support. Files that merely *use* `^{...}` but still parse -(e.g. `src/derivation/WorkflowEvents.scala`, `test/unit/CCTest.scala`) stay formatted normally. +(e.g. `src/shared/derivation/WorkflowEvents.scala`, `test/shared/unit/CCTest.scala`) stay +formatted normally. ⚠️ `scala-cli fmt --check` reports a scalafmt parse error but still **exits 0**, silently masking unformatted files. So when you add a new file with CC syntax scalafmt can't parse, add it to @@ -191,17 +316,46 @@ output for parse errors and fails loudly to catch this. ## Testing -- **Unit tests** (no Docker required): `scala-cli test . --test-only 'dapr4s.test.unit.*'`. Tests - across `JsonCodecTest`, `ModelsTest`, `StateCapabilityTest`, `CCTest`, `SubscriberTest`, - `BindingDispatchTest`, `CapabilityHandlerTest` (with `DaprServerTestBase` as a shared helper). -- **Integration tests** (require Docker): `scala-cli test . --test-only 'dapr4s.test.integration.*'`. - They use `testcontainers-scala-munit` with the `TestContainersForAll` pattern and exercise a real - Dapr sidecar. `startContainers()` **must return an already-started container** — call `c.start()` - before returning; the framework does NOT auto-start it. Tests use `withContainers { c => }`. The - `DaprTestContainer` wrapper bridges the testcontainers-scala `SingleContainer` type to the Dapr - Java testcontainers type. Actor, workflow, state, pub/sub, lock, secrets, and service-invocation - capabilities each have a `*ServerTest` here; there are no in-process / mock-context actor tests. -- CI runs unit and integration tests as separate jobs and gates `publish` on **both** passing. +Four legs, exactly as CI runs them (`.github/workflows/ci.yml`, one `test` matrix job with a +`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): + +- **JVM unit tests** (no Docker): `scala-cli test . --test-only 'dapr4s.test.unit.*'`. Shared + suites live in `test/shared/unit/` (`JsonCodecTest`, `ModelsTest`, `StateCapabilityTest`, + `CCTest`, `CapabilityHandlerTest`, derivation tests, ...); JVM-only suites in `test/jvm/unit/` + (`SubscriberTest`, `BindingDispatchTest`, `JobDispatchTest`, `DaprServerTestBase` — they drive + the JVM `DaprAppServer` over real HTTP on `com.sun.net.httpserver` — plus the Jvm* derivation + and models tests). `test/jvm/TestCodecs.scala` (Jackson) and `TestDaprExtensions.scala` are + jvm-only helpers; `test/js/TestCodecsJs.scala` provides the same codec given names over ujson + so the shared suites run unchanged on JS. +- **JVM integration tests** (Docker): + `scala-cli test . --test-only 'dapr4s.test.integration.*'`. Suites in `test/jvm/integration/` + use `testcontainers-scala-munit` with the `TestContainersForAll` pattern against a real Dapr + sidecar. `startContainers()` **must return an already-started container** — call `c.start()` + before returning; the framework does NOT auto-start it. Tests use `withContainers { c => }`. + The `DaprTestContainer` wrapper bridges the testcontainers-scala `SingleContainer` type to the + Dapr Java testcontainers type. The `DaprApp` fixtures in `test/shared/apps/` deliberately + **cross-compile** (`CapabilityHandlerTest` exercises them on Scala.js — do not jvm-tag them); + only the two `*Main.scala` entry points are jvm-only (`test/jvm/apps/`). +- **Scala.js unit tests** (no Docker, no npm, no facade-touching code): + `scala-cli test --js . --exclude test/js/integration --test-only 'dapr4s.test.unit.*'`. + Runs the shared unit suites on the **plain JS backend** under Node. The `--exclude` is + mandatory and load-bearing: the integration suites contain orphan `js.await`, and the plain-JS + linker **wedges on orphan-await test sources** (hangs, no error) — they must not even be + linked on this leg. +- **Scala.js integration tests** (Docker + Node >= 25 first on PATH + the ST facades + + `npm ci`): `scripts/test-js-integration.sh`. Brings up redis + placement + scheduler + daprd + 1.17 + the Wasm-packaged `JsTestServer` (`scripts/js-integration-env.sh`; port map's Scala twin + is `test/js/integration/JsItEnv.scala` — keep in sync), runs the 8 munit suites (26 tests) in + `test/js/integration/` on the **Wasm+JSPI backend**, tears down. Harness facts: + `scripts/wasm-test.sh` tolerates the known scala-cli bug of exiting 1 after a green Wasm run + (`DirectoryNotEmptyException` on the linked-output dir); an ESM resolution hook + (`scripts/js-it/node-resolve-hook.mjs`) lets the `/tmp`-linked test module import `@dapr/dapr` + from the repo's node_modules (ESM ignores NODE_PATH/CWD); `--test-only` is **ineffective on + the JS test runner** — the unit suites run alongside, harmlessly; `java.util.UUID.randomUUID()` + does **not link** on Scala.js (SecureRandom is absent from the javalib) — use + `JsItEnv.uniqueId()`. +- CI gates `publish` on the `format` job and **both** matrix legs passing. - After every non-trivial change: compile first, then run unit tests, then integration tests if relevant. Do not batch large changes and test only at the end. @@ -319,6 +473,7 @@ probing class files whenever you need to verify an API. via `?=>` context functions is sufficient and keeps the user API minimal. - No async/`Future`-based API — the library is synchronous and blocking, designed for virtual threads. -- No exposing Reactor/Mono types to users — all Reactor is confined to `internal/`. -- No client-side WorkflowCapability yet (starting/querying/terminating workflow instances) — - server-side hosting (`Workflow`, `WorkflowActivity`, `WorkflowContext`) is implemented. +- No exposing Reactor/Mono types to users — all Reactor is confined to `src/jvm/internal/`. +- No runtime stubs for platform-absent building blocks — a capability the platform SDK lacks + (jobs/conversation on JS) is compile-time absent via the platform-trait technique, never an + `UnsupportedOperationException`. diff --git a/README.md b/README.md index 35aa07e..04caae0 100644 --- a/README.md +++ b/README.md @@ -11,30 +11,50 @@ cryptography, jobs, and conversation (LLM) — are modelled as `scala.caps.Capability` values that the compiler tracks. User code compiles under `import language.experimental.safe`, so the compiler statically guarantees that a Dapr resource can never escape the scope that owns it. The Dapr -Java SDK is hidden entirely; users see only Scala types. +SDKs (Java SDK on the JVM, `@dapr/dapr` on Scala.js) are hidden entirely; users +see only Scala types. ## Requirements -- Scala `3.9.0-RC1-…-NIGHTLY` (capture checking / safe mode; see `project.scala`) -- JVM 25 -- [scala-cli](https://scala-cli.virtuslab.org/) -- Docker (for the integration tests, which spin up a real `daprd` sidecar + Redis - via testcontainers) +- Scala `3.10.0-RC1-…-NIGHTLY` (capture checking / safe mode; exact version pinned in `project.scala`) +- [scala-cli](https://scala-cli.virtuslab.org/) `>= 1.13.0` (older versions cannot build the Scala.js platform; `>= 1.14` to run the Scala.js integration harness, `scripts/test-js-integration.sh`) +- JVM 25 (for the JVM platform) +- For Scala.js builds: `npm ci` + `scripts/generate-st-facades.sh` (once per machine — see below); + Node 25+ to run capability-touching code +- Docker (only for the integration tests, which exercise a real `daprd` sidecar + Redis) ## Build & test +Sources live in `src/{shared,jvm,js}` and `test/{shared,jvm,js}` (platform-specific files also +carry a per-file `//> using target.platform` directive). Platform-specific *dependencies* live in +`jvm-deps.scala` / `jvm-test-deps.test.scala` / `js-deps.scala`, each scoped by its own +`target.platform` directive — so plain `--js` invocations just work, with no `--exclude` flags +for dependency scoping. + ```bash -scala-cli compile . -scala-cli test . --test-only 'dapr4s.test.unit.*' # unit tests -scala-cli test . --test-only 'dapr4s.test.integration.*' # needs Docker +scala-cli compile . # JVM +scala-cli test . --test-only 'dapr4s.test.unit.*' # JVM unit tests +scala-cli test . --test-only 'dapr4s.test.integration.*' # JVM integration tests (needs Docker) + +scripts/generate-st-facades.sh # once per machine, before any --js build +scala-cli compile --js . # Scala.js +scala-cli test --js . --exclude test/js/integration --test-only 'dapr4s.test.unit.*' # Scala.js unit tests +PATH=:$PATH scripts/test-js-integration.sh # Scala.js integration tests (Docker + Node 25) ``` +The one remaining `--exclude` (of `test/js/integration`) exists only because those Wasm-only +suites use orphan `js.await`, which wedges the plain-JS linker; the integration script runs +them on the Wasm backend instead, against a real `daprd` 1.17 + Redis environment. + ## Usage ```scala -//> using dep "com.github.sideeffffect::dapr4s:" +//> using dep "com.github.sideeffffect::dapr4s::" ``` +(The `::` before the version resolves the right platform artifact — `dapr4s_3` on +the JVM, `dapr4s_sjs1_3` on Scala.js.) + ```scala import dapr4s.* @@ -43,8 +63,68 @@ DaprCapability.state(StateStoreName("statestore")): // StateCapability^{cap} i // Using StateCapability here — outside the block — is a compile error. ``` -See [`DESIGN.md`](docs/DESIGN.md) for the architecture and the two-layer -(safe / `@assumeSafe` shell) model, and +## Platforms + +The public API is identical on the JVM and on Scala.js — synchronous, direct style +on both. On the JVM, blocking calls park virtual threads; on Scala.js, the same +calls suspend the WebAssembly stack via JSPI (JavaScript Promise Integration) +while the Node event loop keeps running. + +**Scala.js supports the full capability matrix** — state, pub/sub, invocation, +bindings, secrets, configuration, locks, crypto, actors, workflows, and +`serve()` with full app-channel parity (subscriptions, invoke routes, input +bindings, job routes, actor hosting, workflow hosting) — **except `jobs` and +`conversation`**, which the Dapr JS SDK has no API for. On Scala.js those two +methods simply don't exist at compile time (they live on a JVM-only platform +trait), so using them is a compile error, not a runtime exception — use the JVM +platform for those. + +JS consumers of capabilities must link with the experimental WebAssembly backend +and run on Node 25+ (or Node 23/24 with `--experimental-wasm-jspi`); the pure +parts of dapr4s (models, codecs, derivation) also link on the plain JS backend: + +```scala +//> using platform "scala-js" +//> using jsEmitWasm true +//> using jsModuleKind "es" +//> using jsEsVersionStr "es2017" +//> using dep "com.github.sideeffffect::dapr4s::" +``` + +Run `npm install @dapr/dapr` so the SDK is resolvable from the directory Node +executes in, then enter `js.async { ... }` once at the program edge (or use the +JS-only `runAsync`/`serveAsync`, which wrap it for you): + +```scala +import dapr4s.* +import scala.scalajs.js + +// one-shot request/response, with the single js.async entry at the program edge: +def main(args: Array[String]): Unit = + js.async { + Dapr().run: + summon[DaprCapability].state(StateStoreName("statestore")).get(StateStoreKey("k")) + }: Unit +``` + +### Scala.js facades: embedded — consumers need only Maven Central + +The `dapr4s_sjs1_3` artifact is **self-contained**: the ScalablyTyped-generated facades of +`@dapr/dapr`/express/Node that the JS interop layer is written against ship **inside the +dapr4s jar** (under the dapr4s-specific `dapr4styped.*` package, so they can never collide +with a `typings.*` generation of your own), and the POM references only Maven-Central +artifacts. Using dapr4s from Scala.js is therefore an ordinary dependency — no converter, no +local ivy repository, nothing beyond the `using dep` above plus `npm install @dapr/dapr`. + +**Building dapr4s itself** (this repository) is different: the facades are compile-only +dependencies resolved from `~/.ivy2/local`, so run `npm ci` followed by +`scripts/generate-st-facades.sh` once per machine before the first `scala-cli compile --js .`. +The facade digests are deterministic in (package-lock.json, converter version, converter +flags), the script is idempotent and skips instantly when the jars already exist, and at +publish time `scripts/embed-st-facades.sh` packs the generated classes into the jar. + +See [`DESIGN.md`](docs/DESIGN.md) for the architecture, the two-layer +(safe / `@assumeSafe` shell) model, and the Scala.js platform details, and [dapr4s-examples](https://github.com/sideeffffect/dapr4s-examples) for runnable examples. ## Sponsors diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 04c3e71..2e7cfea 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2,10 +2,12 @@ ## Goal -A Scala 3 library that exposes every DAPR building block as a **tracked capability**. User code compiles under `import language.experimental.safe` and `import language.experimental.captureChecking`. The DAPR Java SDK is completely hidden — users see only Scala types. +A Scala 3 library that exposes every DAPR building block as a **tracked capability**. User code compiles under `import language.experimental.safe` and `import language.experimental.captureChecking`. The underlying Dapr SDK is completely hidden — users see only Scala types. **Requires**: Scala `3.10.0-RC1-bin-20260607-dec42ae-NIGHTLY` (or later nightly). The `import language.experimental.safe` and `import language.experimental.captureChecking` features are fully supported in nightly builds — no `-Ycc` flag is needed. +**Platforms**: JVM and Scala.js, with a byte-identical public API (modulo the platform-trait surface, see below). On the JVM the internal layer wraps the Dapr **Java SDK**; on Scala.js it wraps the Dapr **JS SDK** (`@dapr/dapr`). Both SDKs are confined behind the same `@assumeSafe` wall (`src/jvm/internal/` and `src/js/internal/` respectively) — see the [Scala.js platform](#scalajs-platform) section. Sources are organised as `src/{shared,jvm,js}` and `test/{shared,jvm,js}` (see Project Structure). + Each DAPR effect (state access, pub/sub, service calls, secrets, configuration, bindings) is tracked as a captured capability via `^` annotations. The Scala 3 compiler statically verifies: - Which DAPR effects a computation may perform. @@ -37,7 +39,7 @@ graph TB DA["DaprApp (case class)\n+ Subscription / InvokeRoute / BindingRoute"] end - subgraph "Internal Layer (@assumeSafe boundaries)" + subgraph "Internal Layer — JVM (@assumeSafe boundaries, src/jvm/internal/)" DR["Dapr(config).run { ... }"] DS2["Dapr(config).serve { ... }"] IMPL["*CapabilityImpl\n(non-safe-mode,\n@assumeSafe methods)"] @@ -62,15 +64,50 @@ graph TB SRV -->|"GET /dapr/subscribe\nPOST /"| SID ``` +On Scala.js, everything above the internal layer is the **same source code**; only the internal layer is swapped for a JS twin (package `dapr4s.internal`, sources in `src/js/internal/`): + +```mermaid +graph TB + subgraph "Shared across platforms (identical sources)" + UC2["User code (safe mode)"] + API2["Public API: capability traits, DaprApp,\nopaque types, derivation macros"] + end + + subgraph "Internal Layer — Scala.js (@assumeSafe boundaries, src/js/internal/)" + JR["Dapr(config).run / serve\n(src/js/Dapr.scala)\n+ JS-only runAsync / serveAsync"] + JIMPL["*CapabilityImpl JS twins\n(JsAwait: orphan js.await over js.Promise)"] + JDC["DaprClient (JS SDK, HTTP protocol)\n+ lazy gRPC DaprClient\n+ lazy DaprWorkflowClient"] + JFETCH["raw sidecar HTTP via fetch\n(actor client, ActorContext)"] + JSRV["DaprAppServer twin\n(express 4, js.async per request)"] + JWF["WorkflowRuntime (JS SDK)\n+ WorkflowCoroutine\n(AsyncGenerator bridge)"] + end + + subgraph "DAPR Sidecar" + SID2["HTTP :3500 / gRPC :50001"] + end + + UC2 --> API2 + API2 -->|"implemented by"| JIMPL + JR -->|"provides DaprCapability ?=>"| UC2 + JIMPL --> JDC + JIMPL --> JFETCH + JR --> JSRV + JSRV --> JWF + JDC -->|"HTTP/gRPC"| SID2 + JFETCH -->|"HTTP"| SID2 + JSRV -->|"app channel"| SID2 + JWF -->|"gRPC work-item stream"| SID2 +``` + ### Layer 1 — Public API (safe-mode-compatible) -Capability traits, opaque domain types, and the `Dapr(config).run` / `.serve` entry points (on `class Dapr(config: DaprConfig)` in `src/Dapr.scala`). These compile cleanly under both safe mode and capture checking. No Java types are visible. +Capability traits, opaque domain types, and the `Dapr(config).run` / `.serve` entry points (on `class Dapr(config: DaprConfig)` — `src/jvm/Dapr.scala` on the JVM, `src/js/Dapr.scala` on Scala.js, same public signatures). These compile cleanly under both safe mode and capture checking. No Java or JS SDK types are visible. ### Layer 2 — Internal implementations (`@assumeSafe`) -Non-safe-mode Scala that wraps `DaprClient` Java SDK calls. Each class/object is marked `@scala.caps.assumeSafe` (from the `scala.caps` package) so safe-mode user code may call them through the capability trait interfaces. Library authors are responsible for the safety contract; user code cannot add new `@scala.caps.assumeSafe` annotations (the annotation is itself restricted to non-safe-mode code). +Non-safe-mode Scala that wraps the platform SDK calls — the Java SDK's `DaprClient` in `src/jvm/internal/`, the JS SDK's `DaprClient`/`DaprWorkflowClient`/`WorkflowRuntime` (plus express and raw `fetch`) in `src/js/internal/`. Each class/object is marked `@scala.caps.assumeSafe` (from the `scala.caps` package) so safe-mode user code may call them through the capability trait interfaces. Library authors are responsible for the safety contract; user code cannot add new `@scala.caps.assumeSafe` annotations (the annotation is itself restricted to non-safe-mode code). -Note: safe mode is enabled **per-file** via `import language.experimental.safe` (not globally via a compiler flag). This is intentional: files in `internal/` and `Dapr.scala`/`JsonCodec.scala` must use `@scala.caps.assumeSafe` and therefore cannot have the safe-mode import. +Note: safe mode is enabled **per-file** via `import language.experimental.safe` (not globally via a compiler flag). This is intentional: files in `internal/` (both platforms) and `jvm/Dapr.scala`/`js/Dapr.scala`/`JsonCodec.scala` must use `@scala.caps.assumeSafe` and therefore cannot have the safe-mode import. --- @@ -93,7 +130,7 @@ classDiagram +jobs JobsCapability^this +conversation(componentName: ConversationComponentName) ConversationCapability^this } - note for DaprCapability "Root capability. Companion object provides transformer API:\nDaprCapability.state(name) { ... } introduces StateCapability into body scope" + note for DaprCapability "Root capability. Companion object provides transformer API:\nDaprCapability.state(name) { ... } introduces StateCapability into body scope.\njobs + conversation come from the JVM platform trait DaprCapabilityPlatform —\non Scala.js they do not exist at compile time (see Scala.js platform section)" class StateCapability { <> +get[T](key: StateStoreKey) Option[T] @@ -255,7 +292,7 @@ def placeOrder(req: OrderRequest)(using StateCapability, PublishCapability): Ord Each capability trait has a companion object that mirrors every instance method as a static forwarder taking `using cap: CapabilityType`. This makes the call-site type act as both a static effect declaration (which capabilities are required) and as a namespace for the API: ```scala -// src/Capabilities.scala +// src/shared/Capabilities.scala object StateCapability: def save[T: JsonCodec](key: StateStoreKey, value: T)(using cap: StateCapability): Unit = cap.save(key, value) @@ -285,7 +322,7 @@ object OrderServiceApp: } ``` -Transformer signature (in `src/DaprCapability.scala`): +Transformer signature (in `src/shared/DaprCapability.scala`): ```scala object DaprCapability: def state(storeName: StateStoreName)[T](body: StateCapability ?=> T)(using cap: DaprCapability): T = @@ -379,13 +416,13 @@ All domain identifiers are opaque to prevent accidental misuse (e.g., passing a | `ActorId` | `String` | no | Dapr virtual actor instance ID | | `HttpMethod` | enum | — | HTTP verb used by an incoming service invocation | -Each opaque type lives in its own file under `src/optypes/` (one type per file), not in `Models.scala` companions. Smart constructors validate non-empty constraints at construction time (non-empty types). Extension methods provide `.value` unwrapping. +Each opaque type lives in its own file under `src/shared/optypes/` (one type per file), not in `Models.scala` companions. Smart constructors validate non-empty constraints at construction time (non-empty types). Extension methods provide `.value` unwrapping. --- ## Value Types -Structured data without identity, compared by value. Defined in `Models.scala`. These correspond to the `value` and `entity` declarations in the spec's Value Types and Entities sections. +Structured data without identity, compared by value. Defined in `src/shared/Models.scala` — except the jobs and conversation models (`JobSchedule`, `JobDetails`, `ConversationMessage` and friends), which are JVM-only surface and live in `src/jvm/JobsModels.scala` / `src/jvm/ConversationModels.scala`. These correspond to the `value` and `entity` declarations in the spec's Value Types and Entities sections. | Type | Scala form | Purpose | |---|---|---| @@ -529,83 +566,155 @@ Internal catch clauses use `scala.util.control.NonFatal` to ensure fatal JVM err ## Project Structure (Scala CLI) +Sources are split into `src/{shared,jvm,js}` and `test/{shared,jvm,js}`. The directory layout is for humans — scala-cli has no platform directory convention, so every file under a `jvm/`/`js/` directory also carries its own `//> using target.platform "jvm"`/`"scala-js"` directive (that directive is what actually scopes the file). Everything under `shared/` cross-compiles. Packages are unchanged by the layout (e.g. `src/jvm/internal/` and `src/js/internal/` are both package `dapr4s.internal`). + ``` dapr4s/ -├── project.scala # Scala CLI directives (deps, compiler options; nightly Scala) +├── project.scala # Scala CLI directives: platforms jvm + scala-js, nightly Scala, +│ # compiler options, cross deps (scala-java-time; munit/upickle test deps) +├── jvm-deps.scala # JVM-only main deps (Dapr Java SDK), scoped to the JVM by its own +│ # `//> using target.platform "jvm"` directive (no --exclude needed) +├── jvm-test-deps.test.scala # JVM-only test deps (testcontainers): test scope from the .test.scala +│ # suffix + platform scope from target.platform — deliberately NOT test.dep, +│ # which is not platform-scoped and would leak into the JS test build +├── js-deps.scala # Scala.js-only deps: the ScalablyTyped facade coordinates as compileOnly +│ # deps (org.scalablytyped::dapr__dapr/express/node from ~/.ivy2/local; +│ # embedded into the published jar) + their Central-hosted runtime libs +├── publish-conf.scala # CI publishing config (git:dynver version, central, env credentials) +├── package.json # npm pins for the JS layer: @dapr/dapr (runtime + converter input), +│ # @types/express + @types/node (converter inputs), typescript (converter tool) +├── package-lock.json # committed — the ScalablyTyped digests are deterministic in it +├── scripts/ +│ ├── generate-st-facades.sh # ScalablyTyped conversion → ~/.ivy2/local (pins converter tuple + digests) +│ ├── embed-st-facades.sh # stage the facade classes for embedding into the published _sjs1_3 jar +│ ├── test-js-integration.sh # JS integration entry point: env up → munit on Wasm+JSPI → env down +│ ├── js-integration-env.sh # redis + placement + scheduler + daprd 1.17 + the packaged JS test server +│ ├── wasm-test.sh # `scala-cli test` wrapper tolerating the known wasm cleanup bug +│ ├── js-it/ # daprd component YAMLs, secrets.json, Node ESM resolution hooks +│ │ # (node-resolve-hook.mjs + node-resolve-delegate.mjs) +│ └── k8s-test.sh ├── src/ -│ ├── Models.scala # Value types: StateEntry, ConfigurationItem, StateOp, SubscriptionResult, -│ │ # CloudEvent, InvokeRequest, WorkflowSnapshot/Status [safe mode] -│ ├── JsonCodec.scala # JsonCodec typeclass + default instances [@assumeSafe] -│ ├── Capabilities.scala # All capability traits (DaprCapability subtypes + WorkflowCapability) [safe mode] -│ ├── DaprApp.scala # DaprApp case class + Subscription/InvokeRoute/BindingRoute [@assumeSafe companions] -│ ├── DaprCapability.scala # DaprCapability trait with ^{this} return types [safe mode] -│ ├── Dapr.scala # class Dapr(config) with .run + .serve entry points [@assumeSafe] -│ ├── DaprConfig.scala # DaprConfig / SidecarConfig / AppServerConfig / ActorRuntimeConfig -│ ├── Actors.scala # ActorContext, ActorDefinition, ActorRoutes + route types -│ ├── Workflows.scala # Workflow, WorkflowActivity, ActivityDef, Task, WorkflowContext -│ ├── Exceptions.scala # ETagMismatchException, JsonDecodeException -│ ├── optypes/ # One opaque domain type per file (StateStoreName, Topic, AppId, -│ │ # SerializedJson, ApiToken, DaprPort, DaprDuration, ... ) -│ └── internal/ -│ ├── DaprCapabilityImpl.scala # DaprCapability implementation -│ ├── MonoOps.scala # Reactor Mono → blocking bridge (.toFuture().get()) -│ ├── NullOps.scala # null-handling helpers -│ ├── DaprAppServer.scala # HTTP server (OpenJDK jdk.httpserver); workflow/actor registration -│ ├── StateCapabilityImpl.scala -│ ├── PublishCapabilityImpl.scala -│ ├── InvokeCapabilityImpl.scala -│ ├── SecretsCapabilityImpl.scala -│ ├── ConfigurationCapabilityImpl.scala -│ ├── BindingsCapabilityImpl.scala -│ ├── LockCapabilityImpl.scala -│ ├── ActorCapabilityImpl.scala -│ ├── HttpActorContext.scala -│ ├── WorkflowCapabilityImpl.scala -│ ├── WorkflowContextImpl.scala -│ └── WorkflowBridges.scala # WorkflowBridge / WorkflowActivityBridge (Java SDK adapters) +│ ├── shared/ # cross-compiled sources (no platform directive) +│ │ ├── Models.scala # Value types: StateEntry, ConfigurationItem, StateOp, SubscriptionResult, +│ │ │ # CloudEvent, InvokeRequest, WorkflowSnapshot/Status +│ │ ├── JsonCodec.scala # JsonCodec typeclass + default instances [@assumeSafe] +│ │ ├── Capabilities.scala # Cross-platform capability traits + companions [safe mode] +│ │ ├── DaprApp.scala # DaprApp case class + Subscription/InvokeRoute/BindingRoute/JobRoute +│ │ ├── DaprCapability.scala # DaprCapability trait (extends DaprCapabilityPlatform) + companion +│ │ │ # (extends DaprCapabilityCompanionPlatform) [safe mode] +│ │ ├── DaprConfig.scala # DaprConfig / SidecarConfig / AppServerConfig / ActorRuntimeConfig +│ │ ├── Actors.scala # ActorContext, ActorDefinition, ActorRoutes + route types +│ │ ├── Workflows.scala # Workflow, WorkflowActivity, ActivityDef, Task, WorkflowContext +│ │ ├── Validation.scala # DaprAppValidationError + structural validation (validateOrThrow) +│ │ ├── Charsets.scala # Charset constants/encoding helpers usable from safe-mode code +│ │ ├── Exceptions.scala # ETagMismatchException, JsonDecodeException +│ │ ├── optypes/ # One opaque domain type per file (StateStoreName, Topic, AppId, +│ │ │ # SerializedJson, ApiToken, DaprPort, DaprDuration, PemPath, JobName, ...) +│ │ └── derivation/ # Macro derivation layer: per-capability derive engines (State, Publish, +│ │ # Invoke, Secrets, Configuration, Bindings, Crypto, Subscriptions, +│ │ # InvokeRoutes/BindingRoutes/JobRoutes, WorkflowActivities/-Calls, +│ │ # WorkflowEvents, Actor/ActorState/ActorDefinitions, MacroSupport, +│ │ # Forwarders — extends ForwardersPlatform) +│ ├── jvm/ # [every file: target.platform jvm] +│ │ ├── Dapr.scala # JVM entry point: class Dapr(config) with .run + .serve [@assumeSafe] +│ │ ├── DaprCapabilityPlatform.scala # JVM platform surface: jobs + conversation factory methods and +│ │ │ # the companion transformer twins (the JS twin is empty) +│ │ ├── JobsCapability.scala # JVM-only capability trait + companion forwarders +│ │ ├── JobsModels.scala # JobSchedule, JobDetails +│ │ ├── ConversationCapability.scala # JVM-only capability trait + companion forwarders +│ │ ├── ConversationModels.scala # ConversationMessage/Role/Tool/ToolCall/Response, ... +│ │ ├── PemPathJvm.scala +│ │ ├── derivation/ +│ │ │ ├── Jobs.scala # JVM-only Jobs.derive engine +│ │ │ └── ForwardersPlatform.scala # jobs runtime forwarders (the JS twin is empty) +│ │ └── internal/ # JVM internal layer — Java SDK confined here +│ │ ├── DaprCapabilityImpl.scala +│ │ ├── MonoOps.scala # Reactor Mono → blocking bridge (.toFuture().get()) +│ │ ├── FluxOps.scala # Reactor Flux subscription bridge (configuration subscribe) +│ │ ├── NullOps.scala / Json.scala +│ │ ├── DaprAppServer.scala # HTTP server (OpenJDK jdk.httpserver); workflow/actor registration +│ │ ├── State/Publish/Invoke/Secrets/Configuration/Bindings/Lock/Actor/ +│ │ │ Crypto/Jobs/Conversation/Workflow CapabilityImpl.scala +│ │ ├── HttpActorContext.scala +│ │ ├── WorkflowContextImpl.scala +│ │ └── WorkflowBridges.scala # WorkflowBridge / WorkflowActivityBridge (Java SDK adapters) +│ └── js/ # [every file: target.platform scala-js] +│ ├── Dapr.scala # Scala.js entry point: same public run/serve signatures +│ │ # + JS-only runAsync/serveAsync [@assumeSafe] +│ ├── DaprCapabilityPlatform.scala # deliberately EMPTY platform traits — no jobs/conversation on JS +│ ├── derivation/ForwardersPlatform.scala # empty twin +│ └── internal/ # Scala.js internal layer — @dapr/dapr confined here, via the +│ │ # ScalablyTyped-generated dapr4styped.* facades (see Scala.js platform section) +│ ├── facade/ExpressModule.scala # THE one hand-written facade: express CJS default-export shim +│ ├── JsAwait.scala # THE orphan-js.await bridge (only home of allowOrphanJSAwait) +│ ├── JsInterop.scala # JSON/string/error bridging (JS analogue of Json.scala + NullOps) +│ ├── DaprCapabilityImpl.scala # + LazyClientRef, SidecarConfig → SDK options mapping +│ ├── State/Publish/Invoke/Secrets/Configuration/Bindings/Lock/Crypto +│ │ CapabilityImpl.scala # twins (HTTP or gRPC client) +│ ├── ActorCapabilityImpl.scala # actor client over raw sidecar HTTP (fetch) +│ ├── HttpActorContext.scala # ActorContext over raw sidecar HTTP (fetch) +│ ├── WorkflowCapabilityImpl.scala # workflow client over DaprWorkflowClient (gRPC) +│ ├── DaprAppServer.scala # express-based app-channel server twin +│ ├── WorkflowHost.scala # server-side workflow/activity hosting (WorkflowRuntime) +│ ├── WorkflowCoroutine.scala # AsyncGenerator coroutine bridge (see Scala.js platform section) +│ └── WorkflowContextImpl.scala └── test/ - ├── TestCodecs.scala # shared test JsonCodec instances - ├── TestDaprExtensions.scala # test-only Dapr.runWithEndpoints(http, grpc) helper - ├── TestOptionCodec.scala - ├── unit/ - │ ├── ModelsTest.scala - │ ├── JsonCodecTest.scala - │ ├── CCTest.scala # capture checking invariants (ScopeContainment, JsonCodec) - │ ├── CapabilityHandlerTest.scala - │ ├── DaprServerTestBase.scala # in-memory DaprAppServer test harness base - │ ├── StateCapabilityTest.scala # mock-based tests: state, pubsub, secrets, config, lock - │ ├── BindingDispatchTest.scala - │ └── SubscriberTest.scala # DaprAppServer dispatch logic (no Docker required) - └── integration/ - ├── TestDaprApp.scala # In-process DaprApp dispatch helper for tests (@assumeSafe) - ├── DaprTestContainer.scala # Testcontainers bridge - ├── StateIntegrationTest.scala - ├── PubSubIntegrationTest.scala - ├── InvokeIntegrationTest.scala - ├── OrderServiceIntegrationTest.scala - ├── InventoryServiceIntegrationTest.scala - ├── EndToEndIntegrationTest.scala - ├── SecretsIntegrationTest.scala - ├── StateCapabilityServerTest.scala # *CapabilityServerTest: live-sidecar capability tests - ├── PublishCapabilityServerTest.scala - ├── SecretsCapabilityServerTest.scala - ├── LockCapabilityServerTest.scala - ├── ActorCapabilityServerTest.scala - ├── InvokeCapabilityServerTest.scala - ├── WorkflowCapabilityServerTest.scala - └── apps/ - ├── Shared.scala # Shared domain models (OrderRequest, OrderEvent, etc.) - ├── OrderServiceApp.scala # `object OrderServiceApp { def apply()(using …): DaprApp }` + handlers (no @assumeSafe) - ├── InventoryServiceApp.scala - ├── OrderServiceMain.scala # @main entry point (serve OrderServiceApp()) - ├── InventoryServiceMain.scala - ├── CounterActorApp.scala - ├── CounterActorShared.scala - ├── WorkflowApp.scala - ├── TestDurations.scala - └── TestUpickleCodec.scala + ├── shared/ # cross-compiled tests + fixtures + │ ├── TestOptionCodec.scala + │ ├── unit/ # ModelsTest, JsonCodecTest, CharsetsTest, CCTest, DaprAppValidationTest, + │ │ # ActorDefinitionsTest, CapabilityDerivationTest, InvokeDerivationTest, + │ │ # ServerRouteDerivationTest, WorkflowActivityDerivationTest, + │ │ # WorkflowEventsTest, CapabilityHandlerTest, StateCapabilityTest (+ fixtures) + │ └── apps/ # cross-compiling DaprApp fixtures: Shared, OrderServiceApp, + │ # InventoryServiceApp, EchoServiceClient, CounterActorApp/-Shared, + │ # WorkflowApp, TestDurations, TestUpickleCodec + ├── jvm/ # [every file: target.platform jvm] + │ ├── TestCodecs.scala # shared test JsonCodec instances (Jackson) + │ ├── TestDaprExtensions.scala # test-only Dapr.runWithEndpoints(http, grpc) helper + │ ├── unit/ # JVM-server tests: SubscriberTest, BindingDispatchTest, JobDispatchTest, + │ │ # DaprServerTestBase, JvmCapabilityDerivationTest (+ fixtures), + │ │ # JvmModelsTest, JvmServerRouteDerivationTest + │ ├── integration/ # Docker/testcontainers suites. Harnesses: DaprTestContainer, JvmItComponents + │ │ # (renders the shared scripts/it/components set), SharedDaprItSuite (single + │ │ # all-components redis sidecar — direct-call shells), RedisFixture (redis for the + │ │ # bespoke server-delivery suites), TestDaprApp. Thin shells over test/shared + │ │ # scenarios: State/Secrets/Lock/Crypto/Configuration/Invoke ItTest. Server- + │ │ # delivery: Publish/Actor/Workflow/Jobs/Conversation CapabilityServerTest, + │ │ # PubSubIntegrationTest, Order/Inventory/EndToEnd IntegrationTests + │ └── apps/ # OrderServiceMain / InventoryServiceMain @main entry points + └── js/ # [every file: target.platform scala-js] + ├── TestCodecsJs.scala # same given names over ujson, so shared tests cross-run + └── integration/ # Wasm+JSPI thin shells over the SAME test/shared scenarios, against a live + # sidecar: State/PubSub/Invoke/Secrets/Configuration/Lock/Actor/Workflow/Crypto + # JsIntegrationTests + JsTestServer (the served app) + JsItEnv (env twin) ``` +### Integration-test coverage parity + +Every capability the JS SDK supports is integration-tested on **both** platforms against a live `daprd`, and the two +platforms share as much as is reasonable — see [JVM-JS-PARITY.md](JVM-JS-PARITY.md) for the full design. + +- **One component set, redis everywhere.** `scripts/it/components/*.yaml` + `scripts/it/secrets.json` are the single + source of truth, rendered per topology by `scripts/it/render-components.sh` (the only environment-specific value is + `redisHost`: `localhost:6391` for the JS host-network harness, `redis:6379` for the JVM testcontainers network). The + JS harness mounts the rendered dir into daprd; the JVM feeds the *same files* via `DaprContainer.withComponent(Path)` + (`JvmItComponents` renders them; `SharedDaprItSuite`/`RedisFixture` stand up the redis the manifests point at). Both + platforms therefore run state/pubsub/lock/configuration on `redis`, secrets on `local.file`, crypto on + `localstorage` — identical backends, no `state.in-memory` divergence, no `scripts/jvm-it/` twin. +- **Shared scenarios, thin shells.** Each capability's calls + assertions live once as a trait in + `test/shared/scenarios` (`self: munit.Assertions =>`, shared API + `given DaprCapability`). The JVM and JS suites are + thin shells that own only bring-up and the sync/`Future` boundary, then call the same scenarios — so the assertions + are literally shared, not merely "equivalent". Direct-call capabilities (state, secrets, lock, crypto, configuration, + invoke) reduce to `withDapr(scenario)` / `run(scenario)` one-liners. +- **Irreducibly platform-specific bring-up.** Server-delivery suites (actor, workflow, pub/sub delivery) keep + platform-specific harnesses — a host `DaprAppServer` thread the sidecar calls back into on the JVM, the external + `JsTestServer` Node process on JS — because the server runtimes differ. They still run on the shared redis components. + +The only capabilities not tested on Scala.js are **jobs** and **conversation**: the JS SDK has no such APIs, so they are +*compile-time absent* on that platform (`DaprCapabilityPlatform`, see the platform-trait section) — not untested. +**Bindings** is the one shared capability with no live-sidecar suite on either platform (covered by derivation + unit +tests on both); that gap is symmetric by design. + --- ## Key Design Decisions @@ -614,9 +723,9 @@ dapr4s/ |---|---|---| | Capability root | `DaprCapability` provides factory methods | Single entry point; child capabilities capture scope, preventing escape | | JSON library | upickle | Pure Scala, Scala CLI friendly, automatic derivation | -| Async model | Blocking (`.toFuture().get()` on `Mono`) | Direct-style compatible; avoids bringing in effect library dependency; CAS-based VT-safe bridging | +| Async model | JVM: blocking (`Mono.toFuture().get()`) on virtual threads; JS: JSPI suspension via orphan `js.await` on the Wasm backend | Direct-style API on both platforms with no effect-library dependency; VT parking and JSPI stack suspension are architectural analogues (see Scala.js platform section) | | Error model | Exceptions (Java SDK `DaprException`) | Consistent with safe mode's exception-permitting stance; composable with `Try` | -| Java SDK visibility | Zero — all Java types in `internal/` | Users see only Scala types; easier to swap SDK in future | +| SDK visibility | Zero — Java SDK confined to `src/jvm/internal/`, JS SDK (`@dapr/dapr`) confined to `src/js/internal/` | Users see only Scala types; easier to swap SDKs in future | | Scope safety | Capture checking: capabilities `^{scope}` | Compiler enforces no DAPR resource outlives its `Dapr(config).run` block (via `import language.experimental.captureChecking`, no `-Ycc` needed) | | Configuration | Typed `DaprConfig` (`SidecarConfig` / `AppServerConfig` / `ActorRuntimeConfig`) | All endpoints/timeouts/TLS explicit and typed — no env-var reads or system-property manipulation in production code; `grpcTlsInsecure` defaults to `false` | | Capability base type | `scala.caps.ExclusiveCapability` | All capability traits extend `ExclusiveCapability` — the only sealed subtype of `Capability` that prevents sharing. Enables CC separation checking: no capability escapes its scope or is used concurrently. Sub-capabilities return as `^{this}` to bind lifetime to the parent; override methods must explicitly annotate return types to satisfy CC override checks. | @@ -734,6 +843,155 @@ Actor state persists via `/v1.0/actors/{type}/{id}/state`. Reminders are registe --- +## Scala.js platform + +dapr4s cross-compiles to Scala.js with a **byte-identical public API** — except for `jobs`/`conversation`, which exist only on the JVM (see the platform-trait technique below) — backed by the Dapr JS SDK (`@dapr/dapr`). + +### Identical public API — why + +The capability traits, `DaprApp`, the opaque types, and the entire derivation layer are shared sources. An async-on-JS API fork was rejected for two reasons: + +1. The derivation layer generates **synchronous** calls; forking the API would fork every derive engine. +2. The project's documented constraint (see Non-Goals): no async/`Future`-based API — the library is direct-style by design. + +So on JS the same synchronous signatures are preserved, and the asynchrony is absorbed below the public API by Wasm + JSPI. + +### Wasm + JSPI: the virtual-thread analogue + +The Dapr JS SDK is Promise-based. Every JS capability implementation funnels its asynchronous boundary through one helper, `dapr4s.internal.JsAwait.await(p: js.Promise[A]): A` — an **orphan `js.await`** (enabled by the `scala.scalajs.js.wasm.JSPI.allowOrphanJSAwait` import, which appears in that one file only). On the experimental WebAssembly backend, JavaScript Promise Integration (JSPI) **suspends the entire Wasm stack** at this point and returns control to the event loop — no thread is blocked (there are none), inbound work keeps being served, and the stack resumes when the promise settles. This is the exact architectural analogue of a virtual thread parking in `CompletableFuture.get()` on the JVM (`MonoOps.awaitResult`). + +Consequences for JS consumers (documented on `src/js/Dapr.scala`): + +- Link with the **experimental WebAssembly backend**: `//> using jsEmitWasm true`, `//> using jsModuleKind es`, `//> using jsEsVersionStr es2017`. +- Run on **Node 25+** (JSPI on by default), or Node 23/24 with `--experimental-wasm-jspi`. +- `npm install @dapr/dapr` so the SDK resolves from the directory Node executes in. +- Enter `js.async { ... }` **once at the program edge**: `def main = { js.async { Dapr().run { ... } }; () }`. The JS-only conveniences `Dapr#runAsync` / `Dapr#serveAsync` (returning `js.Promise`) wrap this for callers that prefer it. + +**Plain-JS backend: link-time failure by design.** Orphan awaits only link when targeting WebAssembly. The published `_sjs1_3` artifact contains backend-neutral `.sjsir`, so the pure parts of dapr4s (models, codecs, derivation, validation) link fine on the plain JS backend; any code path reaching `Dapr.run`/a capability impl fails **at link time** rather than at runtime. One verified caveat: at least under scala-cli, the plain-JS linker can **wedge** (hang without an error) instead of reporting cleanly when *test* sources contain orphan awaits — which is why the JS unit-test leg excludes `test/js/integration` (see the Scala.js test architecture below). + +### The per-callback `js.async` re-entry rule + +JSPI suspension requires a dynamically enclosing `js.async` on the call stack **with no JavaScript frame in between** (otherwise the engine throws `WebAssembly.SuspendError`). Any Scala lambda invoked *by* a JS API — an express route handler, an SDK activity executor callback, a `Promise.then` reaction — sits below a JS frame. The rule therefore is: **every inbound dispatch re-enters `js.async` per request/invocation** before touching dapr4s code. Each request gets its own suspension scope — like one virtual thread per request on the JVM (`DaprAppServer`'s virtual-thread-per-request executor). + +### Capability support matrix and per-protocol mapping + +The JS SDK cannot serve all building blocks over one protocol (`configuration`/`crypto` throw `HTTPNotSupportedError` over HTTP), so `Dapr.run` owns up to three clients — an HTTP-protocol `DaprClient` (always created) plus a gRPC `DaprClient` and a `DaprWorkflowClient` created lazily on first use (`LazyClientRef`, the JS twin of the JVM's `AtomicReference` pattern) and closed in `run`'s `finally` block: + +| Capability | JS backing | +|---|---| +| state, publish, invoke, bindings (outbound), secrets, lock | HTTP-protocol `DaprClient` sub-clients | +| configuration (get + subscribe), crypto | lazy **gRPC** `DaprClient` (gRPC-only in the JS SDK) | +| actor (client) + `ActorContext` | **raw sidecar HTTP via Node-global `fetch`** (see below) | +| workflow (client) | lazy `DaprWorkflowClient` (gRPC, vendored durabletask) | +| jobs, conversation | **absent at compile time** — the JS SDK has no jobs or conversation API, so these methods do not exist on the JS platform (see the platform-trait technique below); use the JVM platform | +| `serve()`: subscriptions, invoke routes, input bindings, job routes, actor hosting, workflow hosting | express-based `DaprAppServer` twin + `WorkflowHost` — full app-channel parity with the JVM | + +**Why raw `fetch` for actors**: the SDK's low-level `ActorClientHTTP` is exactly what is needed but is not exported from the package root (and `@dapr/dapr` has no `exports` map, so deep-requiring it is unsupported). The exported `ActorProxyBuilder` derives the actor type string from the JS class `.name` (mangled/minified under Scala.js) and returns a JS `Proxy` that turns every property access into an invocation — hostile to a typed facade. So `ActorCapabilityImpl`/`HttpActorContext` speak the sidecar's actor HTTP API directly over `fetch` + `JsAwait`, the same SDK-bypass precedent as the JVM's `HttpActorContext`. + +`SidecarConfig` mapping: `httpEndpoint` → the HTTP client, `grpcEndpoint` → the gRPC and workflow clients, `apiToken` → `daprApiToken`, `grpcMaxInboundMessageSizeBytes` → the SDK's `maxBodySizeMb`. Everything else (OkHttp pool settings, gRPC-Java keepalive, `maxRetries`, `timeout`, TLS material paths) is JVM-transport-specific and ignored on JS (TLS on/off still follows the endpoint URI scheme). + +### Platform-diverging surface: the platform-trait technique + +When a building block exists in only one platform SDK (`jobs` and `conversation` exist in the Java SDK but not in `@dapr/dapr`), dapr4s does **not** throw `UnsupportedOperationException` on the other platform — the methods simply do not exist there at compile time. The mechanism is an inherited **platform parent trait** pair: + +```scala +// src/shared/DaprCapability.scala (cross-compiled) +trait DaprCapability extends scala.caps.ExclusiveCapability, DaprCapabilityPlatform: ... +object DaprCapability extends DaprCapabilityCompanionPlatform: ... + +// src/jvm/DaprCapabilityPlatform.scala — contributes the JVM-only surface +trait DaprCapabilityPlatform: + this: DaprCapability => // so ^{this} tracks the same capability + def jobs: JobsCapability^{this} + def conversation(componentName: ConversationComponentName): ConversationCapability^{this} +trait DaprCapabilityCompanionPlatform: + def jobs[T](body: JobsCapability ?=> T)(using cap: DaprCapability): T = ... + def conversation(componentName: ConversationComponentName)[T](...): T = ... + +// src/js/DaprCapabilityPlatform.scala — both traits deliberately empty +trait DaprCapabilityPlatform +trait DaprCapabilityCompanionPlatform +``` + +**Why inherited traits rather than a platform-split `DaprCapability` file**: a Scala companion object must sit in the **same file** as its trait, and `DaprCapability`'s companion carries the transformer API — so the trait and companion cannot themselves be forked per platform without duplicating the whole shared surface. Parent traits let the shared file own everything cross-platform while each platform contributes (or withholds) its extra members. The same pattern repeats wherever the surface diverges: `JobsCapability`/`ConversationCapability` and their models live under `src/jvm/` outright, the JVM-only `Jobs.derive` engine lives in `src/jvm/derivation/`, and `dapr4s.derivation.Forwarders extends ForwardersPlatform` (the JVM twin carries the jobs runtime forwarders the generated code calls; the JS twin is empty — `Forwarders.jobRoute` stays shared because the inbound job-trigger side is cross-platform). + +The result: using `DaprCapability.jobs` from Scala.js code is a **compile error** ("value jobs is not a member"), not a runtime surprise, and the published `_sjs1_3` artifact contains no jobs/conversation API at all. + +### The express-based `DaprAppServer` twin + +The JVM deliberately bypasses the Java SDK's server and hand-rolls the Dapr app-channel protocol on `com.sun.net.httpserver`. The JS twin mirrors that decision on **express 4** (a dependency of `@dapr/dapr`, so always installed): the JS SDK's `DaprServer` is unsuitable for the same reasons its Java counterpart was — its pub/sub callbacks strip the CloudEvent envelope (dapr4s hands the full envelope to subscription handlers) and its invocation listener constrains HTTP verbs (dapr4s accepts every verb and reports it in `InvokeRequest`). The twin is identical route-for-route and status-code-for-status-code: `/dapr/subscribe`, `/dapr/config`, pub/sub routes, input bindings, invocations, `/job/`, and the actor protocol routes. Every handler immediately enters a fresh `js.async` (the re-entry rule above). Express-forced differences (registration via `app.all` to preserve verb-agnostic dispatch, exact instead of prefix matching of `/dapr/*` paths, `path-to-regexp` pattern characters in user route strings, Node's default backlog instead of the `httpBacklog == 0` OS-default sentinel) are documented on the class. + +"Blocking forever" (`serve`'s `Nothing` contract) is an orphan await on a never-resolving promise — the JS analogue of `Thread.currentThread().join()`; the express server keeps the event loop alive. SIGINT/SIGTERM stop the listener, drain in-flight requests, close the workflow host, and exit after at most `shutdownGrace`. + +### Workflow hosting: the AsyncGenerator coroutine bridge + +The JS SDK's orchestration executor drives an **async generator** that yields the SDK's own `Task` objects (`await generator.next(prevResult)` per history event). Scala.js cannot write `async function*`, so `WorkflowCoroutine` hand-implements the AsyncGenerator protocol as a non-native `js.Object` class (`next`/`throw`/`return` + `Symbol.asyncIterator`): + +- The dapr4s `Workflow.run` body executes inside its own `js.async` fiber. `Task.await()` = resolve the pending generator *step* promise with `{value: sdkTask, done: false}` (handing over the SDK's own Task instance — the executor `instanceof`-checks it), then orphan-await a fresh *resume* promise. The executor's next `next(v)` / `throw(e)` settles the resume promise, resuming (or failing) the fiber. +- **Strict-alternation safety argument**: the executor awaits every `next()`/`throw()` before processing the next history event, so the generator side and the fiber strictly alternate — at any instant at most one of the two is runnable. Combined with JavaScript's single-threaded execution (JSPI resumes a suspended stack as a promise reaction, never concurrently), each resolver field is written in one phase and consumed-and-cleared in the other; the plain `var`s need no synchronization, and the invariant-breach branches throw loudly if a future SDK version ever drives the generator differently. `generator.return()` is rejected loudly — the vendored executor never calls it. +- **Replay**: each work item re-executes the orchestrator from scratch; when history runs out at an incomplete task, the executor stops driving the generator and the fiber stays suspended on a resume promise nobody will resolve — the whole coroutine graph becomes garbage (abandoned JSPI stacks are collectable by design). This is the JS analogue of the JVM's `OrchestratorBlockedException` unwind. +- **Deterministic `newUuid`**: the JS SDK exposes no deterministic UUID, so `WorkflowContextImpl` mirrors the Java SDK's algorithm (RFC 4122 name-based v5/SHA-1 over `"--"` in the Java SDK's fixed namespace `9e952958-5e33-4daf-827f-2fa12937b875`) via `node:crypto`. Replay-stable per instance; cross-platform UUID equality is a non-goal (an instance always replays on the platform hosting it). + +Registration uses `registerWorkflowWithName`/`registerActivityWithName` with the same simple-class-name rule as the JVM (never `fn.name`, which is mangled under Scala.js). Activities run inside their own per-invocation `js.async`, with the same capability-erasure contract as the JVM `WorkflowActivityBridge`. + +### ScalablyTyped-generated facades + +The JS interop layer's facades over `@dapr/dapr`, express and the Node stdlib are **generated, not hand-written**. `scripts/generate-st-facades.sh` runs the ScalablyTyped converter CLI (`org.scalablytyped.converter:cli_3:1.0.0-beta45` via coursier, flags `--scala 3.3.6 --scalajs 1.21.0 -s es2022 --outputPackage dapr4styped`) over the TypeScript type definitions of the npm packages pinned in `package.json` (`@dapr/dapr` 3.18.0 plus the conversion roots `@types/express` and `@types/node` — top-level *dependencies*, because the converter skips devDependencies; `typescript` itself is a converter requirement). The output is `dapr4styped.*` facade jars published to the **local** ivy repository (`~/.ivy2/local/org.scalablytyped/...`), which `js-deps.scala` pins as **compile-only** deps and scala-cli resolves with zero configuration. Generated code is **never committed** and never published remotely as standalone artifacts — at publish time its classes are embedded into the dapr4s jar (below). + +**Why `--outputPackage dapr4styped` instead of ST's default `typings`**: the facade classes ship inside the published dapr4s jar, and a consumer running its own ScalablyTyped generation always gets `typings.*` (including its own `typings.std`/`typings.node`) — a default-named embedded tree would collide with it at link time. The rename keeps the embedded tree in a dapr4s-owned namespace. It must be a single identifier (the converter parses the flag as one `Name`; a dotted value would be backtick-escaped into one bizarre identifier, not a nested package). + +**Digest contract**: each coordinate's version is `-` (e.g. `3.18.0-d3e034`), where the digest is deterministic in exactly (package-lock.json contents, converter version, converter flags — `--outputPackage` included). `package-lock.json` is committed precisely so the digests reproduce on every machine; the script cross-checks its pinned `EXPECTED_*` digests against `js-deps.scala` and fails loudly on drift. It is idempotent — a marker-jar check makes re-runs instant. + +**The one hand-written exception**: `src/js/internal/facade/ExpressModule.scala`. ST's entry point for calling the express module captures the module as a namespace import, which under Node ES modules is never callable (`express()` throws `TypeError`), and `express.text` lost its type to a converter warning — so a small `JSImport.Default` shim provides those two members, typed against the ST-generated `Express`/`Handler` types. Everything else uses `dapr4styped.*` directly. + +**Consumer story**: the published `dapr4s_sjs1_3` artifact is **self-contained** — consumers resolve it from Maven Central like any ordinary dependency and never run the converter. Two mechanisms make that work, both at publish time: (1) the facade deps are `compileOnly.dep`, so the ivy-local-only `org.scalablytyped` coordinates never enter the published POM (scala-cli omits compile-only deps from the POM entirely); (2) `scripts/embed-st-facades.sh` resolves the exact transitive `org.scalablytyped` jar set of the three roots via coursier and unpacks their class/tasty/sjsir entries into a staging dir that `scala-cli publish --js . --resource-dirs .scala-build/st-embed` packs into the jar. The two Maven-Central libraries the generated code itself links against — `com.olvind::scalablytyped-runtime` and `org.scala-js::scalajs-dom` — are declared as regular deps in `js-deps.scala` so they remain in the POM (they used to arrive transitively through the now-absent ST POMs). Only **building dapr4s itself** still requires the generation script (see the README). + +### Build pattern: `target.platform`-scoped dependency files + +A `//> using dep` directive in a file carrying a `//> using target.platform` directive **is scoped to that platform** — so platform-specific dependencies live in three dedicated root files, and no `--exclude` flags are needed for dependency scoping (the one remaining `--exclude test/js/integration` on plain-JS test runs is a linker workaround, see below): + +| File | Platform scope | Contents | +|---|---|---| +| `jvm-deps.scala` | JVM, main | Dapr Java SDK (`io.dapr:dapr-sdk*`) | +| `jvm-test-deps.test.scala` | JVM, test | testcontainers (test scope comes from the `.test.scala` filename suffix) | +| `js-deps.scala` | Scala.js, main | the ScalablyTyped facade coordinates (compileOnly) + scalablytyped-runtime/scalajs-dom | + +The one caveat (empirically verified): `//> using test.dep` is **not** platform-scoped even in a `target.platform`-tagged file — it leaks into the other platform's test build. Hence `jvm-test-deps.test.scala` uses plain `using dep` directives and gets its test scope from the `.test.scala` filename instead. + +Plain `scala-cli compile|test|publish --js .` therefore never resolves the Java SDK or testcontainers, and JVM invocations never resolve the ST facades; the published `_sjs1_3` POM stays free of JVM-only artifacts. Building the JS platform requires scala-cli >= 1.13.0. + +### The Scala.js test architecture + +Scala.js tests run as **two legs**, mirroring the JVM split: + +- **Unit leg** (plain JS backend, no Docker/npm): `scala-cli test --js . --exclude test/js/integration --test-only 'dapr4s.test.unit.*'`. The shared unit suites cross-run unchanged (with `test/js/TestCodecsJs.scala` supplying the codec givens over ujson). The `--exclude` is load-bearing and is the only exclude left in the build: the integration suites contain orphan `js.await`, and the plain-JS linker does not fail on orphan-await test sources — it **wedges** (hangs without error), so they must not even be linked on this leg. +- **Integration leg** (Wasm + JSPI, real sidecar): `scripts/test-js-integration.sh`. Nine munit suites under `test/js/integration/` — state, pub/sub, invoke, secrets, configuration, lock, actors, workflows, crypto — run on the experimental WebAssembly backend against a live environment: `daprd` 1.17 + Redis-backed components + the placement and scheduler services (workflows require the scheduler in 1.17), plus `JsTestServer` — a full dapr4s `serve()` app packaged to Wasm and run under Node as daprd's app channel. `scripts/js-integration-env.sh` brings all of that up/down on non-default ports (its Scala twin is `JsItEnv.scala`); the suites themselves exercise the *client* capabilities through `Dapr().run` while the server side exercises subscriptions, invoke routes, actor hosting and workflow hosting end to end. + +Harness specifics, each compensating for a verified toolchain gap: + +- **Node >= 25 on PATH** — JSPI is on by default there; scala-cli's runner passes no V8 flags, so Node 23/24's `--experimental-wasm-jspi` cannot be injected. +- **`scripts/wasm-test.sh`** wraps `scala-cli test`: scala-cli 1.14.0 always exits 1 after a Wasm test run because its cleanup calls `Files.deleteIfExists` on the linked output, which for Wasm is a non-empty directory (`DirectoryNotEmptyException`). The wrapper tolerates exactly that failure signature (all suites "0 failed", no incomplete runs) and nothing else. +- **ESM resolution hook** (`scripts/js-it/node-resolve-hook.mjs` + `node-resolve-delegate.mjs`, injected via `NODE_OPTIONS=--import`): scala-cli links the test module into `/tmp` and runs Node there; ESM resolution of bare specifiers walks up from the *module's own path* (ignoring both CWD and `NODE_PATH`), so `import '@dapr/dapr'` cannot find the repo's `node_modules` without the hook retrying failed bare specifiers against the repo root. +- `--test-only` is **ineffective on the JS test runner** — the unit suites run alongside the integration suites on this leg (harmlessly; they are fast and environment-free). +- `java.util.UUID.randomUUID()` does **not link** on Scala.js (it reaches for `java.security.SecureRandom`, absent from the javalib) — test ids use a time+`js.Math.random()` scheme (`JsItEnv.uniqueId`). +- **Facade jars on the Wasm link classpath** (`scripts/st-link-jars.sh`): the facades are `compileOnly.dep` (so the ivy-local-only `org.scalablytyped` coordinates stay out of the published POM). `compileOnly` puts them on the classpath the *plain-JS* `test` link uses (the unit leg links fine), but **not** on the one the Wasm backend's `package --test` (building `JsTestServer`) and `test --js-emit-wasm` (running the suites) link against — there the link fails with "Referring to non-existent class dapr4styped…". Both `js-integration-env.sh` and `test-js-integration.sh` therefore resolve the exact transitive `org.scalablytyped` jar set (the same one `embed-st-facades.sh` embeds at publish) and pass it as `--jar` flags; the linker de-duplicates against the compileOnly deps (no duplicate-class errors) and the POM is unaffected. + +### Known platform divergences + +| Area | JVM | Scala.js | +|---|---|---| +| `waitForExternalEvent(name, timeout)` on timeout | throws the Java SDK's `io.dapr.durabletask.TaskCanceledException` | throws `java.util.concurrent.TimeoutException` (the JS SDK has no timeout overload; dapr4s races the event against a durable timer, mirroring the Java SDK's internal mechanism) | +| `Task.isCancelled` | reflects the SDK task state | always `false` — the vendored JS task model has no cancellation state | +| TLS material (`grpcTlsCertPath`/`KeyPath`/`CaPath`, `grpcTlsInsecure`) | honoured | ignored (JVM-only); TLS on/off follows the endpoint URI scheme | +| `SidecarConfig` transport knobs (OkHttp pool, gRPC-Java keepalive, `maxRetries`, `timeout`) | honoured | ignored — the JS SDK exposes no equivalents; `grpcMaxInboundMessageSizeBytes` maps to `maxBodySizeMb` | +| `jobs`, `conversation` | supported | **absent at compile time** — the methods exist only on the JVM platform trait (see the platform-trait technique above); using them on JS is a compile error | +| Duplicate workflow/activity registration name | silently keeps the first registration | the JS SDK registry throws at registration time (a loud failure for what is a bug either way) | +| `try`/`finally` around a never-completing `Task.await()` | finalizer runs on every replay (the `OrchestratorBlockedException` unwind passes through it) | finalizer does not run (the fiber is abandoned mid-suspension) — out of contract on both platforms anyway: workflow code must be effect-free outside activities | +| `continueAsNew` unwind signal | Java SDK `ContinueAsNewInterruption` (a `RuntimeException` — `NonFatal` would match it; contract: never catch it) | dapr4s's own `ContinueAsNewSignal extends ControlThrowable` — same contract, enforced (a broad `NonFatal` catch cannot swallow it) | +| Shutdown ordering | workflow runtime closed after the HTTP drain completes | runtime stop initiated as soon as the listener stops accepting (a JS signal listener cannot block on the drain) | + +--- + ## Non-Goals (v1) -- Reactive/async API (Mono/Flux exposed to users) — use blocking for simplicity. +- Reactive/async API (Mono/Flux or `Future`s exposed to users) — direct style only. On Scala.js the same direct-style API is achieved via Wasm+JSPI suspension (see the Scala.js platform section) rather than by adding an async API; the JS-only `runAsync`/`serveAsync` conveniences merely wrap the program-edge `js.async`, they do not fork the API. diff --git a/docs/JVM-JS-PARITY.md b/docs/JVM-JS-PARITY.md new file mode 100644 index 0000000..858e6c3 --- /dev/null +++ b/docs/JVM-JS-PARITY.md @@ -0,0 +1,97 @@ +# JVM ↔ Scala.js test & config parity + +Status: done (PR #38). Goal: the JVM and Scala.js variants should share as much +production code, test logic, and Dapr configuration as is *reasonable*, and have equal +integration-test coverage for every cross-platform capability. + +Outcome: one shared component set (`scripts/it/components`, redis everywhere), shared +scenario traits in `test/shared/scenarios` driving both platforms, and thin per-platform +shells. Direct-call capabilities (state, secrets, lock, crypto, configuration, invoke) +share the full call+assertion logic; server-delivery suites (actor, workflow, pub/sub +delivery) keep platform-specific bring-up but run on the same shared redis components. +Verified locally: all JVM integration suites + the full JS Wasm+JSPI leg green. + +## What is already shared (baseline) + +- All platform-agnostic production code lives in `src/shared` (models, opaque types, the + capability traits, the whole `derivation` package). Platform code is the thin + `*Impl`/`*Platform` layer in `src/jvm` and `src/js`. +- The capture-checked **direct style** means call sites are *identical* across platforms: + `StateCapability.get(key)` returns `Option[...]` directly on both (JSPI hides the JS async). +- Test apps/fixtures (`EchoService`, `IncrRequest`, `CounterState`, the workflow/actor apps) + already live in `test/shared/apps`. +- `Jobs` and `Conversation` are legitimately JVM-only (absent from the Dapr JS SDK); the + platform trait makes them compile-time absent on JS, so they correctly have no JS tests. + +## Gaps this work closes + +### 1. Dapr component definitions (the `configstore.yaml`-without-a-JVM-twin smell) + +Previously the two platforms described the *same* components two different ways **and with +different backends**: + +| component | JVM (before) | JS (before) | unified | +| --- | --- | --- | --- | +| state | `state.in-memory` | `state.redis` | **`state.redis`** (actorStateStore) | +| pubsub | `pubsub.in-memory` | `pubsub.redis` | **`pubsub.redis`** | +| secrets | `secretstores.local.env` | `secretstores.local.file` | **`secretstores.local.file`** | +| configuration | `configuration.redis` | `configuration.redis` | `configuration.redis` | +| lock | `lock.redis` | `lock.redis` | `lock.redis` | +| crypto | `crypto.dapr.localstorage` | `crypto.dapr.localstorage` | `crypto.dapr.localstorage` | + +**Unification:** one canonical set under `scripts/it/components/*.yaml` is the single source +of truth. The only environment-specific value is `redisHost`, kept as a `${DAPR4S_IT_REDIS_HOST}` +placeholder and rendered per topology: + +- **JS** topology: `--network host`, redis on host port 6391 → `localhost:6391`, daprd reads the + rendered files via `--resources-path` (as today). +- **JVM** topology: testcontainers shared `Network`, redis alias `redis` → `redis:6379`, fed to + `io.dapr.testcontainers.DaprContainer.withComponent(java.nio.file.Path)` (the file-ingesting + overload — verified present in testcontainers-dapr 1.17.2). + +In-container paths are standardized to `/dapr4s-it` so crypto (`/dapr4s-it/keys`) and secrets +(`/dapr4s-it/secrets.json`) need no templating — only `redisHost` does. + +### 2. Integration-test coverage / naming + +- Every cross-platform capability has an integration suite on **both** platforms. +- Close the remaining gaps (e.g. JS had no invoke/secrets *error-path* twin of the JVM + `*IntegrationTest`). +- One naming scheme across platforms. + +### 3. Shared scenario logic + +The *bring-up* (per-suite testcontainers vs one external Docker+Node sidecar) and the *munit +boundary* (synchronous on JVM vs `Future` via `js.async{}.toFuture` on JS) genuinely cannot be +shared. The **scenario** (the capability calls + assertions) can. + +**Design:** each capability's direct-call scenarios become methods on a shared trait in +`test/shared` with `self: munit.Assertions =>`, bodies using only the shared API and a +`given DaprCapability`. Both platforms mix the trait in; each suite owns only bring-up and the +async wrapper, then calls the shared scenario. + +```scala +// test/shared — shared, compiles on both platforms +trait StateScenarios { self: munit.Assertions => + def saveThenGet(using DaprCapability): Unit = + val k = StateStoreKey("k"); StateCapability.save(k, "v") + assertEquals(StateCapability.get[String](k), Some("v")) +} +// JVM shell: test("..."){ withDapr { saveThenGet } } (synchronous) +// JS shell: test("..."){ js.async { Dapr(cfg).run { saveThenGet } }.toFuture } +``` + +**Boundary discovered:** the JVM `*CapabilityServerTest` suites call the capability *from inside +a `DaprAppServer` route handler over HTTP* — they additionally exercise server route dispatch. +The shared scenario backbone targets the **direct-call** form (what the JVM `*IntegrationTest` +suites and all JS suites already do). Server-dispatch coverage stays a per-platform layer +(JVM `DaprAppServer` routes; JS `JsTestServer`), since the server runtimes differ. + +## Execution order (incremental, format → compile → test after each) — all done + +1. ✅ Canonical `scripts/it/components/*.yaml` + `render-components.sh`. +2. ✅ JS harness assembles the rendered dir (components + secrets.json + RSA key) at `/dapr4s-it`. +3. ✅ JVM suites on the shared set: `SharedDaprItSuite` (direct-call) + `RedisFixture` (server-delivery); + state in-memory→redis, secrets local.env→local.file, actor/workflow/pubsub/app-level in-memory→redis. +4. ✅ Shared scenario traits + thin shells for the direct-call capabilities + invoke; JS invoke error-path gap closed. +5. ✅ Verified locally — every JVM integration suite + the full JS Wasm+JSPI leg pass on the shared redis components. diff --git a/docs/SPEC-crypto-jobs-conversation.md b/docs/SPEC-crypto-jobs-conversation.md index 4a25b00..760e54e 100644 --- a/docs/SPEC-crypto-jobs-conversation.md +++ b/docs/SPEC-crypto-jobs-conversation.md @@ -31,7 +31,7 @@ Each block follows the established idiom exactly: on `object DaprCapability`; - the Java SDK is confined to `internal/*Impl.scala`; no Java types leak into the public API; -- new opaque domain types live in `src/optypes/`, value models in `Models.scala`. +- new opaque domain types live in `src/shared/optypes/`, value models in `Models.scala` (JVM-only models in `src/jvm/JobsModels.scala`/`ConversationModels.scala`). ## SDK surface (verified via javap on 1.17.2) @@ -130,7 +130,7 @@ private concrete `ConversationMessage` for alpha2. - **Unit** (`test/`): codec/model round-trips; `JobSchedule` → expression mapping; `ChatRole` ↔ SDK enum mapping; `JobRoute` dispatch through `DaprAppServer`/`TestDaprApp`. -- **Integration** (`test/integration`, Testcontainers + `DaprContainer`): +- **Integration** (`test/jvm/integration`, Testcontainers + `DaprContainer`): - Crypto: component `crypto.dapr.localstorage` with a generated local key; encrypt→decrypt round-trip. - Conversation: component `conversation.echo`; `converse` returns the echoed prompt; `chat` returns it as assistant content. - Jobs: **risk** — the Jobs trigger needs the Dapr **scheduler** service, which the diff --git a/docs/SPEC.allium b/docs/SPEC.allium index a07d79f..23425da 100644 --- a/docs/SPEC.allium +++ b/docs/SPEC.allium @@ -28,6 +28,9 @@ external entity DaprSidecar { external entity JavaDaprClient { -- Managed entirely inside @assumeSafe boundaries; opaque to user code + -- Platform note: on Scala.js the same role is played by the Dapr JS SDK client + -- (@dapr/dapr DaprClient + DaprWorkflowClient, in src/js/internal/), equally + -- opaque to user code and equally managed inside @assumeSafe boundaries. } external entity Workflow { diff --git a/docs/derivation.md b/docs/derivation.md index 6fcee6f..16fcf92 100644 --- a/docs/derivation.md +++ b/docs/derivation.md @@ -169,7 +169,7 @@ It supersedes the loose sketch above where they disagree. different wire name is required. No automatic case conversion. 4. **Validation against examples — dapr4s test apps first.** - The slice is proven inside `dapr4s`'s own `test/integration/apps` before the + The slice is proven inside `dapr4s`'s own `test/shared/apps` before the separate `dapr4s-examples` repo is touched. ## Why the trait must be "faithful" (capture-checking) @@ -305,7 +305,7 @@ method. Two non-obvious constraints shaped the body: metadata)` and the requested `Resp`. Derive a small trait, call its methods with stub codecs, assert the recorded values — proves name mapping, overload selection, and argument forwarding. -* **Integration:** refactor a caller in `test/integration/apps` to obtain its +* **Integration:** refactor a caller in `test/shared/apps` to obtain its remote calls through a derived service, confirming the slice compiles under `language.experimental.safe` + capture checking and behaves identically over a real sidecar. @@ -532,7 +532,7 @@ object MyApp: ``` Proven by `WorkflowActivityDerivationTest` (Docker-free, recording fakes) and the refactored -`test/integration/apps/WorkflowApp.scala` (compiles under safe mode; exercised by the real-sidecar +`test/shared/apps/WorkflowApp.scala` (compiles under safe mode; exercised by the real-sidecar `WorkflowCapabilityServerTest`). --- diff --git a/docs/validation.md b/docs/validation.md index 022fade..5b286ac 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -234,7 +234,7 @@ Unit tests (no sidecar needed) covering: - Root-route collision across kinds (binding vs invocation on the same path). - Reserved-path collisions for each reserved path/prefix. - `validateOrThrow` aggregates *all* errors into one exception message. -- `validationErrors` is empty for the example apps under `test/integration/apps`. +- `validationErrors` is empty for the example apps under `test/shared/apps`. - Actor build with duplicate method names throws; clean actor builds succeed. - `serve` rejects an invalid app before binding the port (can be asserted by constructing the server path with an invalid `DaprApp` and expecting the diff --git a/js-deps.scala b/js-deps.scala new file mode 100644 index 0000000..c86b241 --- /dev/null +++ b/js-deps.scala @@ -0,0 +1,54 @@ +//> using target.platform "scala-js" +// Scala.js-only main-scope dependencies and settings, the JS twin of jvm-deps.scala: the +// `target.platform` directive above scopes the `using dep`/`using js*` directives in this file +// to the Scala.js platform, so JVM builds never see them. +// +// jsEsVersionStr es2017 is required by js.async/js.await (used by the JS internal layer); +// scala-java-time provides java.time on Scala.js (java.time.Instant is part of the public +// WorkflowSnapshot/Models API) — on the JVM the JDK provides java.time, so neither belongs in +// project.scala. +//> using jsEsVersionStr "es2017" +//> using dep "io.github.cquiroz::scala-java-time::2.6.0" +// +// ==ScalablyTyped-generated facades== +// +// The three compileOnly deps below are Scala.js facades GENERATED from the TypeScript type +// definitions of the npm packages pinned in package.json (@dapr/dapr 3.18.0, @types/express +// 4.17.21, @types/node 22.13.0) by scripts/generate-st-facades.sh, into the dapr4s-specific +// `dapr4styped.*` package (see the script header for why not the default `typings.*`). They +// are published into the LOCAL ivy repository (~/.ivy2/local/org.scalablytyped/...) — never to +// a remote repository and never committed — so every machine that BUILDS dapr4s (developer or +// CI) must run that script once before the first `scala-cli compile --js .` (and again +// whenever the digests change). scala-cli resolves ivy2Local out of the box, no configuration +// needed. +// +// The version suffix after the npm version (e.g. `-d3e034`) is the converter's deterministic +// digest of (package-lock.json contents, converter version, converter flags — --outputPackage +// included). To update: +// 1. change the pinned versions in package.json and run `npm install`, +// 2. run scripts/generate-st-facades.sh — it prints the new coordinates, +// 3. update the three deps below AND the matching digest variables at the top of the script +// (the script fails loudly if this file and its variables ever disagree). +// +// Consumer note: consumers of the published dapr4s _sjs1_3 artifact need NOTHING beyond Maven +// Central. The facade classes are EMBEDDED in the published jar: at publish time, +// scripts/embed-st-facades.sh unpacks the sjsir/tasty/class entries of every org.scalablytyped +// jar the three roots transitively require into a staging dir that `scala-cli publish +// --resource-dirs` packs into dapr4s_sjs1_3.jar. The deps are `compileOnly.dep` (not `dep`) so +// that the ivy-local-only org.scalablytyped coordinates never appear in the published POM — +// verified: scala-cli 1.14 omits compileOnly deps from the POM entirely (not even scope +// `provided`). Embedding + compileOnly is the whole trick; the two regular deps below it are +// the Central-hosted runtime libraries the generated facade code itself links against, which +// must stay in the POM precisely because the org.scalablytyped POMs that used to carry them +// transitively are gone. +// +//> using compileOnly.dep "org.scalablytyped::dapr__dapr::3.18.0-d3e034" +//> using compileOnly.dep "org.scalablytyped::express::4.17.21-8ee06b" +//> using compileOnly.dep "org.scalablytyped::node::22.13.0-e98bda" +// +// Runtime (link-time) libraries of the EMBEDDED facade classes — versions are exactly what the +// generated org.scalablytyped POMs reference (com.olvind:scalablytyped-runtime_sjs1_3:2.4.2, +// org.scala-js:scalajs-dom_sjs1_3:2.8.1); re-check them in +// ~/.ivy2/local/org.scalablytyped/dapr__dapr_sjs1_3//poms/ after every regeneration. +//> using dep "com.olvind::scalablytyped-runtime::2.4.2" +//> using dep "org.scala-js::scalajs-dom::2.8.1" diff --git a/js-test-deps.test.scala b/js-test-deps.test.scala new file mode 100644 index 0000000..e2857b9 --- /dev/null +++ b/js-test-deps.test.scala @@ -0,0 +1,8 @@ +//> using target.platform "scala-js" +// Scala.js-only TEST-scope dependencies — the JS twin of jvm-test-deps.test.scala, currently +// empty on purpose (munit and upickle are cross-platform and live in project.scala). +// +// When a JS-only test dependency is needed, add it here as a plain `//> using dep` (NOT +// `test.dep`): `test.dep` directives are not platform-scoped, so the test scoping must come +// from the `.test.scala` filename suffix, with the platform scoping coming from the +// `target.platform` directive above. diff --git a/jvm-deps.scala b/jvm-deps.scala new file mode 100644 index 0000000..1709469 --- /dev/null +++ b/jvm-deps.scala @@ -0,0 +1,16 @@ +//> using target.platform "jvm" +// JVM-only main-scope dependencies (the Dapr Java SDK), kept out of project.scala on purpose. +// +// The `target.platform "jvm"` directive above scopes every `using dep` in this file to the JVM +// platform: `scala-cli compile|test --js .` resolves none of them, which keeps the published +// _sjs1_3 build/POM free of JVM-only artifacts. (This replaces the old `--exclude jvm-deps.scala` +// mechanism — no `--exclude` flags are needed for dependency scoping; the one remaining +// `--exclude test/js/integration` on plain-JS test runs is a linker workaround, see AGENTS.md.) +// +// JVM-only *test* dependencies (testcontainers) live in jvm-test-deps.test.scala: `test.dep` +// directives are not platform-scoped, so the test scoping comes from the `.test.scala` filename +// instead. +// +//> using dep "io.dapr:dapr-sdk:1.17.2" +//> using dep "io.dapr:dapr-sdk-actors:1.17.2" +//> using dep "io.dapr:dapr-sdk-workflows:1.17.2" diff --git a/jvm-test-deps.test.scala b/jvm-test-deps.test.scala new file mode 100644 index 0000000..f3ebd9a --- /dev/null +++ b/jvm-test-deps.test.scala @@ -0,0 +1,16 @@ +//> using target.platform "jvm" +// JVM-only test-scope dependencies (testcontainers, for the Docker-based integration suites). +// +// Two directives combine to scope these to "JVM, test scope": +// - the `.test.scala` filename suffix puts the whole file in test scope, so plain `using dep` +// lines below are test-only — deliberately NOT `using test.dep`, which is not +// platform-scoped and would leak the deps into the Scala.js test build (empirically +// verified); +// - the `target.platform "jvm"` directive scopes them to the JVM platform. +// +//> using dep "com.dimafeng::testcontainers-scala-munit:0.43.6" +//> using dep "io.dapr:testcontainers-dapr:1.17.2" +// testcontainers-scala 0.43.6 pulls TC 1.21.1; testcontainers-dapr 1.17.2 pulls TC 1.21.4. +// Both resolve to 1.21.4 with no conflict. Upgrade to testcontainers-scala 0.44+ only after +// testcontainers-dapr ships a TC 2.x-compatible release (fix merged to dapr/java-sdk master, +// awaiting release as v1.18.0). diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..be885c3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1609 @@ +{ + "name": "dapr4s", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dapr4s", + "license": "Apache-2.0", + "dependencies": { + "@dapr/dapr": "3.18.0", + "@types/express": "4.17.21", + "@types/node": "22.13.0" + }, + "devDependencies": { + "typescript": "5.7.3" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", + "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@connectrpc/connect": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.1.tgz", + "integrity": "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0" + } + }, + "node_modules/@connectrpc/connect-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-2.1.1.tgz", + "integrity": "sha512-s3TfsI1XF+n+1z6MBS9rTnFsxxR4Rw5wmdEnkQINli81ESGxcsfaEet8duzq8LVuuCupmhUsgpRo0Nv9pZkufg==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0", + "@connectrpc/connect": "2.1.1" + } + }, + "node_modules/@connectrpc/connect-web": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.1.1.tgz", + "integrity": "sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0", + "@connectrpc/connect": "2.1.1" + } + }, + "node_modules/@dapr/dapr": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/@dapr/dapr/-/dapr-3.18.0.tgz", + "integrity": "sha512-DBnmV5164wghUc3mGctwHOsjAyA571oHlOpSOcrfsx52uxtF66HSMKfAqhcRjehOjPQuHFNmdpfqqkk+Ws/Suw==", + "license": "apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^2.9.0", + "@connectrpc/connect": "^2.1.0", + "@connectrpc/connect-node": "^2.1.1", + "@connectrpc/connect-web": "^2.1.0", + "@dapr/durabletask-js": "^1.0.0", + "@grpc/grpc-js": "^1.12.5", + "@js-temporal/polyfill": "^0.3.0", + "@types/google-protobuf": "^3.15.5", + "@types/node-fetch": "^2.6.2", + "body-parser": "^1.19.0", + "express": "^4.18.2", + "google-protobuf": "^3.18.0", + "http-terminator": "^3.2.0", + "node-fetch": "^2.6.7" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@dapr/durabletask-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@dapr/durabletask-js/-/durabletask-js-1.0.0.tgz", + "integrity": "sha512-ZcU5JO+I+HF/gIkJmduvh5DfclGRevZNHVMuFc1+7X6eywCYxgtn5vqeitC/ER7QrN7T6MLm7Jf0r+HcOQNSIQ==", + "license": "MIT", + "dependencies": { + "@grpc/grpc-js": "^1.8.14", + "google-protobuf": "^3.21.2" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@js-temporal/polyfill": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.3.0.tgz", + "integrity": "sha512-cxxxis19j0WvK3+kUwKrXeXaDBaWxLeRfKqlVz7g50Cly6UgGs2p3wovH9zjtZ4TtjYHDR4De/880+aalduDZQ==", + "license": "ISC", + "dependencies": { + "big-integer": "^1.6.51", + "tslib": "^2.3.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/google-protobuf": { + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/@types/google-protobuf/-/google-protobuf-3.15.12.tgz", + "integrity": "sha512-40um9QqwHjRS92qnOaDpL7RmDK15NuZYo9HihiJRbYkMQZlWnuH8AdvbMy8/o6lgLmKbDUKa+OALCltHdbOTpQ==", + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.13.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.0.tgz", + "integrity": "sha512-ClIbNe36lawluuvq3+YYhnIN2CELi+6q8NpnM7PYp4hBn/TatfboPgVSm2rwKRfnV2M+Ty9GWDFI64KEe+kysA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delay": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", + "integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-printf": { + "version": "1.6.10", + "resolved": "https://registry.npmjs.org/fast-printf/-/fast-printf-1.6.10.tgz", + "integrity": "sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=10.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/google-protobuf": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.4.tgz", + "integrity": "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==", + "license": "(BSD-3-Clause AND Apache-2.0)" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-terminator": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/http-terminator/-/http-terminator-3.2.0.tgz", + "integrity": "sha512-JLjck1EzPaWjsmIf8bziM3p9fgR1Y3JoUKAkyYEbZmFrIvJM6I8vVJfBGWlEtV9IWOvzNnaTtjuwZeBY2kwB4g==", + "license": "BSD-3-Clause", + "dependencies": { + "delay": "^5.0.0", + "p-wait-for": "^3.2.0", + "roarr": "^7.0.4", + "type-fest": "^2.3.3" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-wait-for": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-wait-for/-/p-wait-for-3.2.0.tgz", + "integrity": "sha512-wpgERjNkLrBiFmkMEjuZJEWKKDrNfHCKA1OhyN1wg1FrLkULbviEy6py1AyJUgZ72YWFbZ38FIpnqvVqAlDUwA==", + "license": "MIT", + "dependencies": { + "p-timeout": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz", + "integrity": "sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/roarr": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-7.21.5.tgz", + "integrity": "sha512-nvelZ4llbfodVanR/gG17H8jpnqgyPX01c4ekQYfoghjEKvAXn7aPPToVG8ngyxf4qtvTC1O5AxQe5PysnF4xg==", + "license": "BSD-3-Clause", + "dependencies": { + "fast-printf": "^1.6.9", + "safe-stable-stringify": "^2.4.3", + "semver-compare": "^1.0.0" + }, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..aa7f6a9 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "dapr4s", + "private": true, + "description": "npm dependency manifest for the dapr4s Scala.js layer — the ScalablyTyped-generated facades (scripts/generate-st-facades.sh, consumed via js-deps.scala) are converted from exactly these versions, and the runtime loads exactly these modules (TypeScript types are erased at runtime, so a floating range could drift undetected). @types/express and @types/node must be top-level dependencies (not devDependencies) or the converter skips them; typescript is required by the converter itself.", + "license": "Apache-2.0", + "dependencies": { + "@dapr/dapr": "3.18.0", + "@types/express": "4.17.21", + "@types/node": "22.13.0" + }, + "devDependencies": { + "typescript": "5.7.3" + } +} diff --git a/project.scala b/project.scala index 625c6ea..75fb7cd 100644 --- a/project.scala +++ b/project.scala @@ -1,4 +1,5 @@ //> using scala "3.10.0-RC1-bin-20260607-dec42ae-NIGHTLY" +//> using platform "jvm" "scala-js" //> using jvm "zulu:25.0.3" //> using options "-language:experimental.captureChecking" //> using options "-language:experimental.pureFunctions" @@ -9,17 +10,20 @@ // Note: -language:experimental.safe is NOT applied globally because non-safe-mode // files (Dapr, JsonCodec, internal/*) need to use @scala.caps.assumeSafe. // Safe mode is enabled per-file via: import language.experimental.safe -//> using dep "io.dapr:dapr-sdk:1.17.2" -//> using dep "io.dapr:dapr-sdk-actors:1.17.2" -//> using dep "io.dapr:dapr-sdk-workflows:1.17.2" -//> using test.dep "org.scalameta::munit:1.3.0" -//> using test.dep "com.lihaoyi::upickle:3.3.1" -//> using test.dep "com.dimafeng::testcontainers-scala-munit:0.43.6" -//> using test.dep "io.dapr:testcontainers-dapr:1.17.2" -// testcontainers-scala 0.43.6 pulls TC 1.21.1; testcontainers-dapr 1.17.2 pulls TC 1.21.4. -// Both resolve to 1.21.4 with no conflict. Upgrade to testcontainers-scala 0.44+ only after -// testcontainers-dapr ships a TC 2.x-compatible release (fix merged to dapr/java-sdk master, -// awaiting release as v1.18.0). +// +// Platforms: "jvm" is listed first, so plain `scala-cli compile/test .` builds the JVM +// platform; select Scala.js with `--js` (no extra flags needed). +// +// Platform-specific dependencies AND platform-specific settings live in dedicated files, +// scoped by a `target.platform` directive (a `using dep`/`using js*` directive in a +// platform-tagged file applies only to that platform's build): +// - jvm-deps.scala — Dapr Java SDK (JVM, main scope) +// - jvm-test-deps.test.scala — testcontainers (JVM, test scope via the .test.scala suffix) +// - js-deps.scala — Scala.js deps (facades, scala-java-time) + js* settings +// - js-test-deps.test.scala — Scala.js test-scope deps (none yet; placeholder for symmetry) +// Only cross-platform deps belong in this file (the `::version` double-colon form). +//> using test.dep "org.scalameta::munit::1.3.0" +//> using test.dep "com.lihaoyi::upickle::3.3.1" //> using publish.organization "com.github.sideeffffect" //> using publish.name "dapr4s" //> using publish.computeVersion "git:dynver" diff --git a/raw/dapr/2026-06-11-dapr-js-sdk-source-survey.md b/raw/dapr/2026-06-11-dapr-js-sdk-source-survey.md new file mode 100644 index 0000000..60b661b --- /dev/null +++ b/raw/dapr/2026-06-11-dapr-js-sdk-source-survey.md @@ -0,0 +1,170 @@ +# Dapr JavaScript SDK (@dapr/dapr) — API map for Scala.js facades + +> Source: github.com/dapr/js-sdk @ a3be700 (= npm v3.18.0, published 2026-06-10) — full source survey of src/index.ts, implementation/{Client,Server}, interfaces, types, actors, workflow, utils, errors; npm registry metadata + 3.18.0 tarball listing; v3.17.0/v3.18.0 release notes; docs.dapr.io JS SDK pages +> Collected: 2026-06-11 +> Published: Unknown + +All findings verified against actual source at `dapr/js-sdk` HEAD (`a3be700`, 2026-06-10), which corresponds to the just-published **v3.18.0**. File paths below are relative to the repo root. + +## 1. Version, runtime targeting, module system + +- **Latest npm version: 3.18.0**, published 2026-06-10 (verified via npm registry). Previous: 3.17.0 (2026-04-23), 3.6.1, 3.5.2… +- **Versioning policy** (from the v3.17.0 release notes): major stays `3`; **the minor now tracks the Dapr runtime minor** ("first release since the 1.17 release of the Dapr runtime, it's released as 3.17.0"). E2E CI defaults to **Dapr runtime 1.16.12** with 1.17.x supported (`.github/workflows/test-e2e-testcontainers.yml`). +- **Node `>=18.0.0`** (`engines` in published package.json). Node-only — depends on `node-fetch@2`, `express@4`, `http`, `http2`, `stream`; no browser build. +- **CommonJS only.** `tsconfig.json`: `module: "commonjs"`, `target: "ES2022"`, `declaration: true`. v3.18.0 explicitly switched generated protos "to emit CommonJS modules instead of ESM" (PR #826). **No `exports` map, no `main` field** in the published package.json; compiled files sit at the **package root** (e.g. `package/index.js`, `package/actors/ActorId.js`, `package/workflow/runtime/WorkflowRuntime.js` — verified by listing the 3.18.0 tarball, 603 files), so Node resolves `index.js` by default. `types: "./index.d.ts"`. +- **Single entry point**: everything is re-exported as **named exports** from `src/index.ts` (no `@dapr/dapr/workflow` subpath; deep requires like `@dapr/dapr/workflow/runtime/WorkflowRuntime` work only because there is no exports map — unsupported API). +- Runtime deps (3.18.0): `express ^4.18.2`, `body-parser`, `node-fetch ^2.6.7`, `http-terminator ^3.2.0`, `@grpc/grpc-js ^1.12.5` (used by the vendored durabletask worker/client), `@connectrpc/connect(+node,+web) ^2.x` + `@bufbuild/protobuf ^2.9` (new gRPC transport for DaprClient/DaprServer since 3.17), `google-protobuf`, `@js-temporal/polyfill ^0.3.0` (actor timer/reminder durations), `@dapr/durabletask-js ^1.0.0` (**leftover**: not imported anywhere in `src/` — durabletask was vendored into `src/workflow/internal/durabletask` in 3.17.0, PR #738; only tests/examples still import it). + +Root exports (`src/index.ts`): `DaprClient`, `DaprServer`, `GRPCClient`, `HTTPClient`, `HttpMethod`, `AbstractActor`, `ActorId`, `ActorProxyBuilder`, `Temporal` (re-export of the polyfill!), `DaprClientOptions`, `LogLevel`, `LoggerOptions`, `LoggerService`, `ConsoleLoggerService`, `InvokerOptions`, `TypeDaprInvokerCallback`, `DaprInvokerCallbackContent`, `CommunicationProtocolEnum`, `DaprPubSubStatusEnum`, `PubSubBulkPublishMessage`, `StateConcurrencyEnum`, `StateConsistencyEnum`, `PubSubBulkPublishResponse`, `StateGetBulkOptions`, `DaprWorkflowClient`, `WorkflowActivityContext`, `WorkflowContext`, `WorkflowRuntime`, `TWorkflow`, `Task`, `WorkflowFailureDetails`, `WorkflowState`, `WorkflowRuntimeStatus`, `fromOrchestrationStatus`, `toOrchestrationStatus`. + +## 2. DaprClient (`src/implementation/Client/DaprClient.ts`) + +```ts +constructor(options: Partial = {}) +static create(client: IClient): DaprClient +static awaitSidecarStarted(fn: () => Promise, logger: Logger): Promise +start(): Promise; stop(): Promise; getIsInitialized(): boolean +``` + +`DaprClientOptions` (`src/types/DaprClientOptions.ts`): +```ts +type DaprClientOptions = { + daprHost: string; // default "127.0.0.1" + daprPort: string; // STRING; default "3500" HTTP / "50001" gRPC + communicationProtocol: CommunicationProtocolEnum; // default HTTP + isKeepAlive?: boolean; // default true + logger?: LoggerOptions; // { level?: LogLevel; service?: LoggerService } + actor?: ActorRuntimeOptions; + daprApiToken?: string; // sent as `dapr-api-token` header / gRPC metadata + maxBodySizeMb?: number; // default 4 +} +``` +Env defaults (`src/utils/Settings.util.ts`): `DAPR_HTTP_PORT`, `DAPR_GRPC_PORT`, `DAPR_API_TOKEN`, `DAPR_HTTP_ENDPOINT`, `DAPR_GRPC_ENDPOINT`, `APP_ID`. **Note:** JSDoc claims a `DAPR_PROTOCOL` env var, but `Settings.getDefaultCommunicationProtocol()` returns the constant HTTP — no env override exists. Constructor throws `Error("DAPR_INCORRECT_SIDECAR_PORT")` on non-numeric port. The gRPC client (`src/implementation/Client/GRPCClient/GRPCClient.ts`) now uses **ConnectRPC** (`createGrpcTransport`/`createClient(Dapr, transport)`), with an interceptor injecting `dapr-api-token`. + +Sub-clients (readonly fields; interfaces in `src/interfaces/Client/`): + +| field | interface | methods (exact TS) | +|---|---|---| +| `state` | `IClientState` | `save(storeName: string, stateObjects: KeyValuePairType[], options?: StateSaveOptions): Promise` · `get(storeName: string, key: string, options?: Partial): Promise` · `getBulk(storeName: string, keys: string[], options?: StateGetBulkOptions): Promise` · `delete(storeName: string, key: string, options?: Partial): Promise` · `transaction(storeName: string, operations?: OperationType[], metadata?: IRequestMetadata | null): Promise` · `query(storeName: string, query: StateQueryType): Promise` | +| `pubsub` | `IClientPubSub` | `publish(pubSubName: string, topic: string, data?: object | string, options?: PubSubPublishOptions): Promise` · `publishBulk(pubSubName: string, topic: string, messages: PubSubBulkPublishMessage[], metadata?: KeyValueType): Promise` | +| `binding` | `IClientBinding` | `send(bindingName: string, operation: string, data: any, metadata?: object): Promise` | +| `invoker` | `IClientInvoker` | `invoke(appId: string, methodName: string, method: HttpMethod, data?: object, options?: InvokerOptions): Promise` (impl defaults `method = HttpMethod.GET`; `InvokerOptions = { headers?: KeyValueType }`) | +| `secret` | `IClientSecret` | `get(secretStoreName: string, key: string, metadata?: string): Promise` · `getBulk(secretStoreName: string): Promise` | +| `configuration` | `IClientConfiguration` | `get(storeName, keys?, metadata?): Promise` · `subscribe(storeName, cb): Promise` · `subscribeWithKeys(storeName, keys, cb)` · `subscribeWithMetadata(storeName, keys, metadata, cb)`; `cb: (res: SubscribeConfigurationResponse) => Promise`, stream = `{ stop: () => void }`. **gRPC-only** — HTTP impl throws `HTTPNotSupportedError` (`src/implementation/Client/HTTPClient/configuration.ts`) | +| `lock` | `IClientLock` | `lock(storeName: string, resourceId: string, lockOwner: string, expiryInSeconds: number): Promise` · `unlock(storeName, resourceId, lockOwner): Promise`; HTTP uses `v1.0-alpha1` endpoints; `enum LockStatus { Success, LockDoesNotExist, LockBelongsToOthers, InternalError }` | +| `crypto` | `IClientCrypto` | overloaded: `encrypt(opts: EncryptRequest): Promise` / `encrypt(inData: Buffer|ArrayBuffer|ArrayBufferView|string, opts: EncryptRequest): Promise`; same shape for `decrypt`. **gRPC-only** (HTTP impl throws). `EncryptRequest = { componentName, keyName, keyWrapAlgorithm: "A256KW"|"A128CBC"|…|"RSA", dataEncryptionCipher?, omitDecryptionKeyName?, decryptionKeyName? }` (`src/types/crypto/Requests.ts`) | +| `workflow` | `IClientWorkflow` | `getWorkflowState(instanceId): Promise` · `scheduleNewWorkflow(workflowName, input?, instanceId?): Promise` · `terminate/pause/resume/purge(instanceId): Promise` · `raiseEvent(instanceId, eventName, eventData?)`; deprecated aliases `get/start/raise` (renamed in 3.18.0, PR #783; aliases removed with Dapr 1.20). **HTTP-only** (uses `v1.0-beta1` HTTP API); gRPC impl throws `GRPCNotSupportedError` (`src/implementation/Client/GRPCClient/workflow.ts`). Prefer `DaprWorkflowClient` | +| `actor` | `IClientActorBuilder` | `create(actorTypeClass: Class): T` (wraps `ActorProxyBuilder`) | +| `proxy` | `IClientProxy` | `create(cls: Class, clientOptions?): Promise` — gRPC-only (HTTP impl throws) | +| `metadata` | `IClientMetadata` | `get(): Promise` · `set(key, value): Promise` | +| `health` | `IClientHealth` | `isHealthy(): Promise` | +| `sidecar` | `IClientSidecar` | `shutdown(): Promise` | + +Supporting types: `KeyValuePairType = { key: string; value: any; etag?: string; metadata?: KeyValueType; options?: IStateOptions }`; `KeyValueType = { [key: string]: any }`; `OperationType = { operation: string /* "upsert"|"delete" */; request: IRequest }` with `IRequest = { key: string; value?: any; etag?: IEtag; metadata?; options?: IStateOptions }`; `IStateOptions = { concurrency: StateConcurrencyEnum; consistency: StateConsistencyEnum }`; `PubSubPublishOptions = { contentType?: string; metadata?: KeyValueType }`; `StateGetBulkOptions = { parallelism?: number; metadata? }` (default parallelism 10). + +**Missing vs Java SDK**: **Jobs** — no client API at all (no schedule/get/delete job); the gRPC server only has a **no-op stub** `onJobEventAlpha1(req, ctx): Promise` returning an empty response (`src/implementation/Server/GRPCServer/GRPCServerImpl.ts:396`). **Conversation** — completely absent. **Streaming pub/sub subscriptions** from the client — absent (subscribe only via DaprServer). Crypto/configuration/proxy are gRPC-only; workflow client building block is HTTP-only. + +## 3. DaprServer (`src/implementation/Server/DaprServer.ts`) + +```ts +constructor(serverOptions: Partial = {}) +start(): Promise // starts app server first, then client.start() (awaits sidecar) +stop(): Promise +readonly pubsub: IServerPubSub; binding: IServerBinding; invoker: IServerInvoker; +readonly actor: IServerActor; client: DaprClient; daprServer: IServer; +``` +`DaprServerOptions` (`src/types/DaprServerOptions.ts`): `{ serverHost: string /* default 127.0.0.1 */; serverPort: string /* default "3000" HTTP, "50000" gRPC */; communicationProtocol: CommunicationProtocolEnum; maxBodySizeMb?: number; serverHttp?: express.Express /* bring-your-own express app */; clientOptions?: Partial; logger?: LoggerOptions }`. Sets `process.env.DAPR_SERVER_PORT` / `DAPR_CLIENT_PORT`. + +- **HTTP mode** (`src/implementation/Server/HTTPServer/HTTPServer.ts`): runs **express 4** with `body-parser` (text, raw for octet-stream, json incl. `application/cloudevents+json`); shutdown via `http-terminator`; serves `GET /dapr/subscribe` returning the programmatic subscription list — so **register subscriptions/handlers before `start()`**. +- **gRPC mode** (`src/implementation/Server/GRPCServer/GRPCServer.ts`): Node `http2.createServer` + ConnectRPC `connectNodeAdapter`, implementing `AppCallback` (`onInvoke`, `listTopicSubscriptions`, `onTopicEvent`, `listInputBindings`, `onBindingEvent`), `AppCallbackAlpha` (`onBulkTopicEventAlpha1`, `onJobEventAlpha1` stub) and `AppCallbackHealthCheck`. + +Server interfaces (`src/interfaces/Server/`): +```ts +// IServerPubSub +subscribe(pubSubName: string, topic: string, cb: TypeDaprPubSubCallback, + route?: string | DaprPubSubRouteType, metadata?: KeyValueType): Promise; +subscribeWithOptions(pubsubName, topic, options: PubSubSubscriptionOptionsType): Promise; +subscribeToRoute(pubsubName, topic, route: string | DaprPubSubRouteType, cb): void; +subscribeBulk(pubSubName, topic, cb, bulkSubscribeOptions?: BulkSubscribeOptions): Promise; +getSubscriptions(): PubSubSubscriptionsType; +// callback: +type TypeDaprPubSubCallback = (data: any, headers: object) => Promise; +// returning DaprPubSubStatusEnum.SUCCESS|RETRY|DROP controls ack; throw => RETRY precedence +// PubSubSubscriptionOptionsType = { metadata?; deadLetterTopic?; deadLetterCallback?; callback?; route?; bulkSubscribe?: BulkSubscribeConfig } +// DaprPubSubRouteType = { rules?: {match, path}[]; default?: string } + +// IServerBinding +receive(bindingName: string, cb: TypeDaprBindingCallback): Promise; +type TypeDaprBindingCallback = (data: any) => Promise; // HTTP: POST / + +// IServerInvoker +listen(methodName: string, cb: DaprInvokerCallbackFunction, options?: InvokerListenOptionsType): Promise; +type DaprInvokerCallbackFunction = (data: DaprInvokerCallbackContent) => Promise; +interface DaprInvokerCallbackContent { body?: string; query?: string; metadata?: { contentType?: string }; headers?: KeyValueType } +type InvokerListenOptionsType = { method?: HttpMethod }; // default GET + +// IServerActor +registerActor(cls: Class): Promise; +getRegisteredActors(): Promise; +init(): Promise; // MUST call before registerActor; registers actor HTTP routes +``` +Actor hosting is **HTTP-only**: `GRPCServerActor` throws `GRPCNotSupportedError` (`src/implementation/Server/GRPCServer/actor.ts`). `HTTPServerActor.init()` registers `GET /healthz`, `GET /dapr/config`, `DELETE /actors/:type/:id`, `PUT /actors/:type/:id/method/:method`, `PUT .../method/timer/:timerName`, `PUT .../method/remind/:reminderName` (`src/implementation/Server/HTTPServer/actor.ts`). + +## 4. Actors + +**Client side**: `ActorId` (`src/actors/ActorId.ts`): `new ActorId(id: string)`, `static createRandomId(): ActorId`, `getId()`, `getURLSafeId()`, `toString()`. `ActorProxyBuilder` (`src/actors/client/ActorProxyBuilder.ts`): overloaded ctor `(actorTypeClass: Class, daprClient: DaprClient)` or `(actorTypeClass, host, port, communicationProtocol, clientOptions)`; `build(actorId: ActorId): T` returns a **JS `Proxy`** that forwards every property access as an async actor-method invocation with `body = args.length > 0 ? args : null`; the actor type string is **`actorTypeClass.name`**. Raw low-level client: `IClientActor` (`src/interfaces/Client/IClientActor.ts`) implemented by `ActorClientHTTP/GRPC`: `invoke(actorType, actorId, methodName, body?)`, `stateTransaction(actorType, actorId, operations)`, `stateGet(actorType, actorId, key)`, `registerActorReminder/unregisterActorReminder`, `registerActorTimer/unregisterActorTimer`, `getActors()`; `ActorReminderType/ActorTimerType = { period?: Temporal.Duration; dueTime?: Temporal.Duration; data?: any; ttl?: Temporal.Duration; callback: string /* timer only */ }`. + +**Server side**: `AbstractActor` (`src/actors/runtime/AbstractActor.ts`): `constructor(daprClient: DaprClient, id: ActorId)`; lifecycle overrides `onActivate()`, `onDeactivate()`, `onActorMethodPre()`, `onActorMethodPost()`, `receiveReminder(data: string)`; helpers `registerActorReminder<_T>(reminderName, dueTime: Temporal.Duration, period?, ttl?, state?)`, `unregisterActorReminder(name)`, `registerActorTimer(timerName, callback: string /* method name */, dueTime, period?, ttl?, state?)`, `unregisterActorTimer(name)`; accessors `getStateManager(): ActorStateManager`, `getDaprClient()`, `getActorId()`, `getActorType()` (= `this.constructor.name`!). `ActorStateManager` (`src/actors/runtime/ActorStateManager.ts`): `addState`, `tryAddState`, `getState(name): Promise`, `tryGetState(name): Promise<[boolean, T|null]>`, `setState`, `removeState`, `tryRemoveState`, `containsState`, `getOrAddState`, `addOrUpdateState(name, value, updateValueFactory)`, `getStateNames`, `clearCache`, `saveState` (auto-called after each method via `onActorMethodPostInternal`). `ActorRuntime.registerActor(actorCls: Class): void` keys managers by **`actorCls.name`**. `ActorRuntimeOptions = { actorIdleTimeout?: string; actorScanInterval?: string; drainOngoingCallTimeout?: string; drainRebalancedActors?: boolean; reentrancy?: { enabled?: boolean; maxStackDepth?: number }; remindersStoragePartitions?: number }`. + +## 5. Workflows (root import, no subpath; durabletask vendored in `src/workflow/internal/durabletask` since 3.17.0) + +- **`DaprWorkflowClient`** (`src/workflow/client/DaprWorkflowClient.ts`) — talks **gRPC directly** to the sidecar via vendored `TaskHubGrpcClient` (@grpc/grpc-js): `constructor(options: Partial = {})` where `WorkflowClientOptions = { daprHost: string; daprPort: string; logger?: LoggerOptions; daprApiToken?: string; grpcOptions?: grpc.ChannelOptions }`. Methods: `scheduleNewWorkflow(workflow: TWorkflow | string, input?: any, instanceId?: string, startAt?: Date): Promise`, `terminateWorkflow(id, output)`, `getWorkflowState(id, getInputsAndOutputs): Promise`, `waitForWorkflowStart(id, fetchPayloads = true, timeoutInSeconds = 60)`, `waitForWorkflowCompletion(id, fetchPayloads = true, timeoutInSeconds = 60)`, `raiseEvent(id, eventName, eventPayload?)`, `purgeWorkflow(id): Promise`, `suspendWorkflow(id)`, `resumeWorkflow(id)`, `stop()`. +- **`WorkflowRuntime`** (`src/workflow/runtime/WorkflowRuntime.ts`): same ctor options; `registerWorkflow(workflow: TWorkflow): WorkflowRuntime`, `registerWorkflowWithName(name: string, workflow: TWorkflow)`, `registerActivity(fn: TWorkflowActivity)`, `registerActivityWithName(name, fn)`, `start()`, `stop()` (wraps vendored `TaskHubGrpcWorker`). Name resolution uses `getFunctionName(fn)` = `fn.name` — **use the `*WithName` variants from Scala.js**. +- **Authoring model**: `type TWorkflow = (context: WorkflowContext, input: any) => Generator, any, any> | TOutput` — in practice an **`async function*` (async generator) yielding `Task` objects**; the executor checks `typeof result?.[Symbol.asyncIterator] === "function"` and drives it via `await generator.next(prevResult)` (`src/workflow/internal/durabletask/worker/orchestration-executor.ts:145`, `runtime-orchestration-context.ts:84-145`). Non-generator return values complete the workflow immediately. Activities: `type TWorkflowActivity = (context: WorkflowActivityContext, input: TInput) => TOutput` (may return a Promise). +- **`WorkflowContext`** (`src/workflow/runtime/WorkflowContext.ts`): `getWorkflowInstanceId(): string`, `getCurrentUtcDateTime(): Date`, `isReplaying(): boolean`, `createTimer(fireAt: Date | number /* seconds */): Task`, `callActivity(activity: TWorkflowActivity | string, input?): Task`, `callSubWorkflow(orchestrator: TWorkflow | string, input?, instanceId?): Task` (alias `callChildWorkflow`), `waitForExternalEvent(name: string): Task`, `continueAsNew(newInput: any, saveEvents: boolean): void`, `setCustomStatus(status: string): void`, `whenAll(tasks: Task[]): WhenAllTask`, `whenAny(tasks: Task[]): WhenAnyTask`. +- **`WorkflowActivityContext`**: `getWorkflowInstanceId(): string`, `getWorkflowActivityId(): number`. **`WorkflowState`** (getters): `name`, `instanceId`, `runtimeStatus: WorkflowRuntimeStatus`, `createdAt`, `lastUpdatedAt`, `serializedInput?`, `serializedOutput?`, `workflowFailureDetails?: WorkflowFailureDetails` (`getErrorType()`, `getErrorMessage()`, `getStackTrace()`), `customStatus?`. `enum WorkflowRuntimeStatus { RUNNING, COMPLETED, FAILED, TERMINATED, CONTINUED_AS_NEW, PENDING, SUSPENDED }` (numeric, from OrchestrationStatus). `Task` public surface: `get isComplete: boolean`, `get isFailed: boolean`, `getResult(): T`, `getException(): TaskFailedError`. +- Inputs/outputs are **JSON-serialized strings** (`JSON.parse(rawInput)` in the executor). + +## 6. Error surfacing + +- HTTP client (`HTTPClient.execute`, `src/implementation/Client/HTTPClient/HTTPClient.ts`): non-2xx/3xx → **rejects with plain `Error` whose message is `JSON.stringify({ error: statusText, error_msg: bodyText, status: number })`**. No typed error hierarchy for API errors. +- gRPC client: rejections are ConnectRPC `ConnectError`s (from `@connectrpc/connect`). +- Soft-failure responses instead of rejections: `pubsub.publish` returns `{ error?: Error }` (`PubSubPublishResponseType`); `state.save`/`delete` return `StateSaveResponseType = { error?: Error }`; `publishBulk` returns `{ failedMessages: { message, error }[] }`. +- Typed errors (`src/errors/`): `GRPCNotSupportedError`, `HTTPNotSupportedError` (protocol-unsupported building blocks), `PropertyRequiredError`. Sidecar startup timeout: `Error("DAPR_SIDECAR_COULD_NOT_BE_STARTED")` after ~60 retries × 500ms. Workflow activity failures surface inside workflows as `TaskFailedError` via `Task.getException()` and on the client as `WorkflowFailureDetails`. + +## 7. Serialization (`src/utils/Serializer.util.ts`, `Deserializer.util.ts`, `Client.util.ts:getContentType`) + +- Content type is **inferred from the JS value** unless overridden: `Object`/`Array` → `application/json` (or `application/cloudevents+json` if it looks like a CloudEvent), `Boolean`/`Number`/`String` → `text/plain`, binary (Buffer/TypedArray) → `application/octet-stream`. +- JSON content types are encoded via `JSON.stringify`; text via `toString()`; binary passed/Buffered raw. Deserialization always **tries `JSON.parse` first** and falls back to the raw string (`tryParseJson`). So `state.get` returns parsed JSON (`KeyValueType`) or a string. +- State save POSTs the JSON array of `{key, value, etag, options}`; metadata goes into **query params** on HTTP (`createHTTPQueryParam`). PubSub publish: `options.contentType` overrides; metadata as query params. Actor method payloads use `BufferSerializer` (JSON in Buffers). Workflow inputs/outputs: JSON strings. + +## 8. Scala.js facade-writing notes / oddities + +- **All classes are TS `export default`** in their files but **re-exported as named exports from the root**, so facades can use `@JSImport("@dapr/dapr", "DaprClient")` etc. The `IClient*`/`IServer*` interfaces and all `*.type.ts` types are **TypeScript-only** (erased) — model as structural `js.Object` traits. +- **CJS, Node-only** (node-fetch v2, express, http2, `stream.Duplex`, `Buffer`); fine for Scala.js-on-Node with ESModule or CommonJS module kind (`import` of CJS works under Node ESM interop since they're named props of the default export — safest is `ModuleKind.CommonJSModule` or `ESModule` with default-import interop). +- **Enums**: `CommunicationProtocolEnum` is **numeric with `GRPC = 0`, `HTTP = 1`** (careless facades defaulting to 0 would pick gRPC!). `HttpMethod` is lowercase strings (`"get"`, …). `DaprPubSubStatusEnum` is strings (`"SUCCESS" | "RETRY" | "DROP"`). `StateConcurrencyEnum`/`StateConsistencyEnum` numeric 0/1/2. `LockStatus` numeric. +- **Ports are strings**, not numbers, everywhere (`daprPort: string`, `serverPort: string`). +- **Constructor overloads**: `ActorProxyBuilder` (2 forms), crypto `encrypt`/`decrypt` (2 overloads each). Options objects are `Partial<...>` — every facade field should be optional/`js.UndefOr`. +- **Class-name reflection hazards**: actor type = `actorTypeClass.name` (proxy builder) and `this.constructor.name` (AbstractActor); workflow/activity registration uses `fn.name`. Scala.js class/lambda names are mangled or minified — for actors you must control the JS class `name` (e.g. define facade-level JS classes or set `static name`), and for workflows always use `registerWorkflowWithName`/`registerActivityWithName` and pass string names to `scheduleNewWorkflow`/`callActivity` (string overloads exist everywhere). +- **Workflow authoring requires an async-generator**: the executor demands `result[Symbol.asyncIterator]` and drives `next(prevResult)`. Scala.js cannot write `async function*`, so a facade must hand-implement the AsyncGenerator protocol (object with `next(v): js.Promise` + `[Symbol.asyncIterator]`) — feasible, but it is the single hardest piece; alternatively drive workflows from a small JS shim, or only support task-free workflows (plain return) which the executor also accepts. +- **Server-side actors extend `AbstractActor`** — Scala.js classes can extend JS classes, but method dispatch happens by JS property name (`ActorRuntime.invoke(actorTypeName, actorId, methodName, body)`), so actor methods must be `@JSExport`-visible with stable names. +- **Temporal**: actor reminders/timers take `Temporal.Duration` from `@js-temporal/polyfill` (re-exported as `Temporal` from the package root) — needs a tiny facade (`Temporal.Duration.from({ seconds: … })`). +- **Ordering**: HTTP `DaprServer` requires all `pubsub.subscribe` / `binding.receive` / `invoker.listen` / `actor.init()+registerActor` calls **before** `server.start()`; `server.actor.init()` must precede `registerActor`. Actors and `client.workflow` are HTTP-only; configuration/crypto/proxy are gRPC-only — a dapr4s capability matrix must encode per-protocol support. +- `invoker.listen` callback receives `body` as a **string** (`JSON.stringify(req.body)`), `query` as the original URL — re-parse on the Scala side. +- No `exports` map → deep imports possible but unstable; stick to root exports. +- Missing building blocks to implement differently or skip in dapr4s-js: **jobs** (no API; only an empty `onJobEventAlpha1` gRPC stub), **conversation** (absent), client-side streaming pubsub subscriptions (absent). + +### Primary sources +- npm registry metadata + 3.18.0 tarball listing (registry.npmjs.org/@dapr/dapr) +- github.com/dapr/js-sdk @ a3be700: `src/index.ts`, `src/implementation/Client/{DaprClient,HTTPClient/*,GRPCClient/*}.ts`, `src/implementation/Server/{DaprServer,HTTPServer/*,GRPCServer/*}.ts`, `src/interfaces/{Client,Server}/*.ts`, `src/types/**`, `src/actors/**`, `src/workflow/**`, `src/utils/{Serializer,Deserializer,Client,Settings}.util.ts`, `src/errors/*` +- Release notes v3.17.0 (versioning policy, durabletask vendoring PR #738) and v3.18.0 (CJS protos PR #826, workflow method renames PR #783) +- docs.dapr.io/developing-applications/sdks/js/ (building-block overview) + +## VERDICT +The Dapr JS SDK (@dapr/dapr 3.18.0, published 2026-06-10, Node >=18, CommonJS-only TypeScript with named root exports and no exports map) is a viable Scala.js facade target for most dapr4s capabilities: DaprClient exposes state, pubsub publish, output bindings, service invocation, secrets, metadata/health/sidecar over both HTTP and gRPC, plus configuration/crypto/proxy (gRPC-only) and a deprecated-style workflow management client (HTTP-only); DaprServer (express for HTTP, http2+ConnectRPC for gRPC) covers pubsub subscribe, input bindings, invocation listeners, and actor hosting (HTTP-only); actors (ActorId/ActorProxyBuilder/AbstractActor/ActorStateManager) and workflows (DaprWorkflowClient/WorkflowRuntime/WorkflowContext, durabletask vendored in-package) are fully present. The hard spots for Scala.js are: workflow bodies must be JS async generators yielding Task objects (requires hand-implementing the AsyncGenerator protocol or a JS shim), actor/workflow registration relies on JS class/function `.name` reflection (use explicit-name registration variants and controlled JS class names), CommunicationProtocolEnum is numeric with GRPC=0, ports are strings, and the SDK is missing jobs (only a no-op gRPC stub), conversation, and client-side streaming subscriptions entirely — those dapr4s capabilities cannot be backed by this SDK and would need raw HTTP/gRPC implementations or omission on the JS platform. + +## BLOCKERS +- Jobs building block has no client API in the JS SDK (only an empty onJobEventAlpha1 gRPC server stub) — dapr4s jobs capability cannot be delegated to @dapr/dapr +- Conversation building block is entirely absent from the JS SDK +- Workflow authoring requires JS async generator functions (async function* yielding Task) driven via Symbol.asyncIterator — Scala.js cannot author these natively; a hand-rolled AsyncGenerator protocol implementation or JS shim is required +- Per-protocol gaps: actors server-side and workflow management client are HTTP-only; configuration, crypto, and proxy are gRPC-only — a single DaprClient protocol choice cannot serve all building blocks \ No newline at end of file diff --git a/raw/scala-js/2026-06-11-cc-on-scalajs-probe.md b/raw/scala-js/2026-06-11-cc-on-scalajs-probe.md new file mode 100644 index 0000000..909f3df --- /dev/null +++ b/raw/scala-js/2026-06-11-cc-on-scalajs-probe.md @@ -0,0 +1,104 @@ +# Scala 3 Capture Checking on Scala.js — research + empirical probe report + +> Source: Empirical probe in /tmp/cc-js-probe (dapr4s nightly 3.10.0-RC1-bin-20260607-dec42ae-NIGHTLY, scala-cli 1.12.2/1.14.0, Node v22.20.0); scala/scala3 Compiler.scala phase list; scala/scala3 issue tracker searches; scala-cli v1.13.0 release notes; Maven Central artifact probes +> Collected: 2026-06-11 +> Published: Unknown + +## TL;DR +Capture checking (plus pureFunctions, per-file `experimental.safe`, `-Yexplicit-nulls`, `-experimental`, `-Wconf:any:error`, `-Ycc-verbose`) **works end-to-end on Scala.js with the exact dapr4s nightly** `3.10.0-RC1-bin-20260607-dec42ae-NIGHTLY`. Compile, link, run on Node, and munit tests all pass. The only toolchain gotcha: **scala-cli >= 1.13.0 is required** (the locally installed 1.12.2 embeds a Scala.js 1.20 linker that cannot read the IR 1.21 emitted by munit 1.3.0's JS artifacts), and JS-platform deps need the `org::name::version` (double-colon-before-version) form. + +## Q1: Known incompatibilities CC x Scala.js backend — NONE found + +- **Architecture**: confirmed from `compiler/src/dotty/tools/dotc/Compiler.scala` on scala/scala3 main — `cc.Setup` and `cc.CheckCaptures` (lines 87–88) live in `picklerPhases`, i.e. the frontend group; `backend.sjs.GenSJSIR` is in `backendPhases` (line 151). Capture sets are type annotations erased long before SJSIR generation, so the JS backend never sees CC artifacts. This matches the "CC is a typer/refchecks-level feature" hypothesis. +- **Issue tracker**: `gh search issues --repo scala/scala3` for "capture checking scala.js", "captureChecking scalajs", "cc Scala.js" → **0 results**. Filtering the `area:capture-checking` label for "scalajs"/"scala.js" → **0 results**. Web searches likewise surfaced no CC-vs-Scala.js bug reports. +- Sources: [scala3 Compiler.scala](https://github.com/scala/scala3/blob/main/compiler/src/dotty/tools/dotc/Compiler.scala), [CC reference docs](https://docs.scala-lang.org/scala3/reference/experimental/cc.html), [issue #19855](https://github.com/scala/scala3/issues/19855), [issue #23027](https://github.com/scala/scala3/issues/23027) (general CC bugs, none JS-specific). + +## Q2: Empirical probe — /tmp/cc-js-probe + +**The nightly resolves for Scala.js.** scala-cli found `scala3-library_sjs1_3-3.10.0-RC1-bin-20260607-dec42ae-NIGHTLY` and `scalajs-scalalib_2.13-3.10.0-RC1-bin-20260607-dec42ae-NIGHTLY` on `repo.scala-lang.org/artifactory/maven-nightlies` (after expected misses on Central snapshots), plus `scalajs-library_2.13`/`scalajs-javalib` from Maven Central. scala-cli 1.12.2 auto-picked Scala.js 1.20.2; scala-cli 1.14.0 picks 1.21.0. + +**Final working `/tmp/cc-js-probe/project.scala`:** +```scala +//> using scala "3.10.0-RC1-bin-20260607-dec42ae-NIGHTLY" +//> using platform scala-js +//> using jsVersion 1.21.0 +//> using jsEsVersionStr es2017 +//> using options "-language:experimental.captureChecking" +//> using options "-language:experimental.pureFunctions" +//> using options "-Ycc-verbose" +//> using options "-Yexplicit-nulls" +//> using options "-experimental" +//> using options "-Wconf:any:error" +//> using test.dep "org.scalameta::munit::1.3.0" +//> using test.dep "com.lihaoyi::upickle::3.3.1" +``` +(The flag set deliberately mirrors `/home/ondra/.t3/worktrees/dapr4s/t3code-c916dd05/project.scala`, including `-Ycc-verbose` and `-Wconf:any:error`.) + +**Sources** (all compile clean, zero diagnostics, under `-Wconf:any:error`): +- `/tmp/cc-js-probe/Probe.scala` — capability trait extending `scala.caps.ExclusiveCapability`, a method taking `FileSystem ?=> A`, capture-set-annotated function type `(String => Unit)^{fs}`, plus a `@js.native @JSGlobal("console")` facade used from CC code. +- `/tmp/cc-js-probe/SafeProbe.scala` — `import language.experimental.safe` per-file: **accepted on JS** (an unknown language feature would have errored). +- `/tmp/cc-js-probe/JsInterop.scala`, `AsyncProbe.scala`, `MainApp.scala`, `probe.test.scala` — see Q3–Q5. + +**One code-level finding** (nightly semantics, not JS-specific): in this nightly `caps.Capability` is **sealed**. My first probe `trait FileSystem extends Capability` failed with: +``` +[error] ./Probe.scala:9:7 +[error] Cannot extend sealed trait Capability in a different source file +``` +User code must extend the classifier subtraits (`scala.caps.ExclusiveCapability`, `SharedCapability`, ...). dapr4s already does this (`trait DaprCapability extends scala.caps.ExclusiveCapability` in `src/DaprCapability.scala`), so this is a non-issue for the cross-build — but any docs/examples extending bare `Capability` will break identically on JVM and JS. + +**Run verification**: `scala-cli run .` (with scala-cli 1.14.0) prints: +``` +hello from capture-checked Scala.js +js.async result: 42 +``` + +## Q3: js.Promise + js.native facades under -Yexplicit-nulls + CC + -Wconf:any:error + +**Zero friction observed.** `/tmp/cc-js-probe/JsInterop.scala` defines a `@js.native trait` with `def ...: String = js.native`, `val version: Int = js.native`, `js.Promise`-returning members, a `@JSGlobal` object, and `.then` chaining (backticked `` `then` `` — Scala 3 keyword, unrelated to CC). It compiles with **no warnings at all**, so `-Wconf:any:error` passes with **no exclusions or `@nowarn` needed**. Explanation: under explicit nulls only *Java-defined* symbols get nullified types; JS facades are Scala-defined, so their member types are taken verbatim (`String`, not `String | Null`). Caveat: if a real-world facade member can return `null` at runtime, you must declare it `X | Null` yourself — a modeling concern, not a compiler friction. + +## Q4: js.async / js.await + +- Available and compiles in both Scala.js 1.20.2 and 1.21.0 (`js.async { js.await(p) + 1 }` in `/tmp/cc-js-probe/AsyncProbe.scala`), no compiler/plugin flag needed. +- **Linker requires ES2017 output.** Without it, linking fails: +``` +Uses an async block with an ECMAScript version older than ES 2017 + called from ccjsprobe.AsyncProbe$.roundTrip(scala.scalajs.js.Promise)scala.scalajs.js.Promise + ... +org.scalajs.linker.interface.LinkingException: There were linking errors +``` +- Fix: `//> using jsEsVersionStr es2017`. After that, runtime output on Node v22.20.0 is correct (`js.async result: 42`). No orphan-await/JSPI flags needed for directly-nested awaits. + +## Q5: munit + upickle on JS — both exist, two real gotchas + +`munit_sjs1_3:1.3.0` and `upickle_sjs1_3:3.3.1` **both exist and resolve**; `scala-cli test .` ends with: +``` +Test run ccjsprobe.ProbeSuite finished: 0 failed, 0 ignored, 2 total +``` +(one test round-trips a `Map` through `upickle.default.write/read`, one exercises a capture-checked closure.) + +**Gotcha A — dependency syntax**: dapr4s's current `//> using test.dep "org.scalameta::munit:1.3.0"` (single colon before version) resolves the **JVM** artifact `munit_3` even on the JS platform. Compilation still succeeds (TASTy is there) but there are no `.sjsir` files, so the test run dies with the misleading `[error] No framework found by Scala.js test bridge` (verbose log confirmed `munit_3-1.3.0.jar` on the JS classpath). The platform-suffixed form `org.scalameta::munit::1.3.0` → `munit_sjs1_3` is required; this form also resolves correctly on JVM, so it is safe to use unconditionally in a cross-build. + +**Gotcha B — linker IR version / scala-cli version**: munit 1.3.0's JS artifacts pull `scalajs-library_2.13:1.21.0` (IR 1.21). The installed scala-cli **1.12.2** embeds a Scala.js **1.20.2** linker in its native binary, which fails hard regardless of `//> using jsVersion 1.21.0`: +``` +org.scalajs.ir.IRVersionNotSupportedException: Failed to deserialize a file compiled with +Scala.js 1.21 (supported up to: 1.20): .../scalajs-library_2.13-1.21.0.jar:/scala/scalajs/runtime/package.sjsir +``` +Fix: scala-cli **>= 1.13.0** ("Support for Scala.js 1.21.0", [release notes](https://github.com/VirtusLab/scala-cli/releases/tag/v1.13.0)). I downloaded the 1.14.0 launcher to `/tmp/scala-cli-1.14.0` (global install untouched); it defaults to Scala.js 1.21.0 (verified with a bare probe in `/tmp/js-default-probe`), so the explicit `jsVersion` pin is optional-but-recommended. Scala.js 1.21.0 (2026-04-04) is the latest release ([scala-js releases](https://github.com/scala-js/scala-js/releases)). **CI implication for dapr4s**: pin/setup scala-cli >= 1.13.0 for any JS build. + +## Q6: Fallback to stable 3.7.x/3.8.x +Not needed — the dapr4s nightly works on JS. (Incidentally, the bare `/tmp/js-default-probe` compile also confirms stable 3.8.3 + Scala.js 1.21.0 works under scala-cli 1.14.0.) + +## Out-of-scope caveats for the actual dapr4s cross-build +- The main deps (`io.dapr:dapr-sdk*`) and test deps (`testcontainers-*`) are JVM-only Java libraries; a JS build needs a different transport/test strategy. That is an architecture question, not a toolchain blocker. +- The transient `Bloop 'bsp' command exited with code 1` seen once with scala-cli 1.12.2 did not reproduce with 1.14.0 (default server mode worked). + +## Artifacts +- Probe project: `/tmp/cc-js-probe/` (`project.scala`, `Probe.scala`, `SafeProbe.scala`, `JsInterop.scala`, `AsyncProbe.scala`, `MainApp.scala`, `probe.test.scala`) +- scala-cli 1.14.0 launcher: `/tmp/scala-cli-1.14.0` + +Sources: [scala3 Compiler.scala phase list](https://github.com/scala/scala3/blob/main/compiler/src/dotty/tools/dotc/Compiler.scala), [Capture Checking docs](https://docs.scala-lang.org/scala3/reference/experimental/cc.html), [scala-cli v1.13.0 release](https://github.com/VirtusLab/scala-cli/releases/tag/v1.13.0), [scala-js releases](https://github.com/scala-js/scala-js/releases), [scala3 issue #19855](https://github.com/scala/scala3/issues/19855), [scala3 issue #23027](https://github.com/scala/scala3/issues/23027), [Capture Checking in Scala 3.4](https://www.scalamatters.io/post/capture-checking-in-scala-3-4), [Introduction to CC and Separation Checking](https://tanishiking.github.io/posts/introduction-to-scala-3s-capture-checking-and-separation-checking/) + +## VERDICT +Capture checking is fully viable on Scala.js with the exact dapr4s nightly (3.10.0-RC1-bin-20260607-dec42ae-NIGHTLY): the complete dapr4s flag set (-language:experimental.captureChecking, pureFunctions, per-file experimental.safe, -Yexplicit-nulls, -Ycc-verbose, -experimental, -Wconf:any:error) compiles, links, runs on Node, and passes munit tests on the JS platform with zero warnings and no -Wconf exclusions — CC is erased in the pickler-phase group before GenSJSIR, and no CC×Scala.js issues exist in the scala/scala3 tracker. The empirically verified requirements are: scala-cli >= 1.13.0 (the installed 1.12.2's embedded 1.20 linker rejects the IR 1.21 in munit 1.3.0's JS artifacts; munit_sjs1_3:1.3.0 and upickle_sjs1_3:3.3.1 otherwise resolve fine), platform-suffixed dependency syntax org::name::version for JS deps, and //> using jsEsVersionStr es2017 for js.async/js.await (available and runtime-verified on Scala.js 1.20.2/1.21.0). + +## BLOCKERS diff --git a/raw/scala-js/2026-06-11-scala-cli-crossplatform.md b/raw/scala-js/2026-06-11-scala-cli-crossplatform.md new file mode 100644 index 0000000..769e6fb --- /dev/null +++ b/raw/scala-js/2026-06-11-scala-cli-crossplatform.md @@ -0,0 +1,137 @@ +# Scala CLI: Cross-compiling & cross-publishing JVM + Scala.js from one source tree + +> Source: Empirical probes in /tmp/sjs-probe, /tmp/sjs-nightly2, /tmp/sjs-wasm (scala-cli 1.12.2 and 1.14.0, Node v22.20.0); scala-cli.virtuslab.org docs (directives reference, Scala.js guide, publish, GH Actions cookbook); VirtusLab/scala-cli releases & issues #1632/#3590/#3591; Maven Central artifact probes +> Collected: 2026-06-11 +> Published: Unknown + +All load-bearing claims below were **verified empirically** in `/tmp/sjs-probe`, `/tmp/sjs-nightly2`, and `/tmp/sjs-wasm` with scala-cli **1.12.2** (locally installed) and **1.14.0** (current latest, via `cs launch scala-cli:1.14.0 --`). Node v22.20.0. + +## 1. Multi-platform directive and cross-building + +**Yes, one directive can declare both platforms.** Both forms verified working: + +```scala +//> using platform jvm scala-js +//> using platforms jvm scala-js // plural alias, identical behavior +``` + +Grammar per the [directive reference](https://scala-cli.virtuslab.org/docs/reference/directives/): `//> using platform (jvm|scala-js|js|scala-native|native)+`. This directive is **not** experimental (no warning emitted), unlike `target.platform` and `publish.*` which are. + +Verified semantics: +- **Plain `scala-cli compile/test/run .` builds only ONE platform: the FIRST one listed.** With `jvm scala-js` it built `(Scala 3.3.6, JVM (17))`; after reordering to `scala-js jvm` it built `(Scala 3.3.6, Scala.js 1.20.2)`. The list order is the default-platform choice. +- **CLI override selects the other platform:** `scala-cli compile --js .` / `scala-cli test --js .` (also `--platform js`). Verified. +- **`--cross` (requires `--power`) runs the command against ALL declared platforms in one invocation.** Help text: `--cross Run given command against all provided Scala versions and/or platforms`. Verified on `compile --cross` (2 "Compiled project" lines: JVM + JS) and **`test --cross`** (ran munit suites twice: once on JVM, then `Running tests for Scala 3.3.6, JS`). Issues [#3590](https://github.com/VirtusLab/scala-cli/issues/3590)/[#3591](https://github.com/VirtusLab/scala-cli/issues/3591) (`run`/`package` `--cross` only compiling) are closed/fixed. + +**Publishing both platforms — ONE invocation with `--cross`** (verified with `publish local`, same code path as remote `publish`): + +``` +scala-cli --power publish local --cross . --ivy2-home /tmp/probe-ivy +→ Publishing io.github.example:sjs-probe_3:0.1.0 +→ Publishing io.github.example:sjs-probe_sjs1_3:0.1.0 +``` + +Both modules got jar + sources jar + javadoc jar + POM. **Without `--cross`, `publish` publishes only the first/default platform** (verified: only `sjs-probe_3` appeared). Two separate invocations (`publish .` and `publish --js .`) also work as an alternative. + +POMs are correct per platform (verified by inspection): +- `sjs-probe_3.pom`: `scala3-library_3:3.3.6`, `upickle_3:3.3.1` +- `sjs-probe_sjs1_3.pom`: `scalajs-library_2.13:1.21.0`, `scala3-library_sjs1_3:3.3.6`, `upickle_sjs1_3:3.3.1` + +Jar contents are correctly platform-split: JS jar contains `.sjsir` + `.class` for shared and js-only files, no jvm-only classes; JVM jar contains jvm-only + shared, no js-only. + +## 2. Per-file platform targeting + +Exact verified syntax (placed at the top of the specific file, NOT in `project.scala`): + +```scala +//> using target.platform jvm // file only compiled for JVM +//> using target.platform scala-js // file only compiled for Scala.js +``` + +- It's a **"require" directive**: the only directive class that applies per-file rather than build-wide ([using-directives guide](https://scala-cli.virtuslab.org/docs/guides/introduction/using-directives): "The only exceptions are `using target` directives, which only apply to the given file"). Marked **experimental** — scala-cli prints a warning for each use (suppress with `--suppress-experimental-feature-warning` or `scala-cli config suppress-warning.experimental-features true`). +- Verified: a file with `import scala.scalajs.js` + `//> using target.platform scala-js` is cleanly skipped on JVM builds and vice versa; jar contents confirm the split survives packaging/publishing. +- **There is NO directory convention** (no `jvm/`/`js/` source-dir magic like sbt-crossproject). scala-cli picks up all `.scala` under the input dirs; you can *organize* files into `src/jvm/`, `src/js/` dirs for readability, but **every platform-specific file must carry its own `target.platform` directive**. Related UX complaint: [issue #1632](https://github.com/VirtusLab/scala-cli/issues/1632). +- **CRITICAL LIMITATION (verified):** `using dep`/`using test.dep` directives written inside a platform-targeted file are NOT scoped to that platform — they leak into all platforms. A `//> using test.dep com.dimafeng::testcontainers-scala-munit::0.43.6` inside a `target.platform jvm` test file made `scala-cli test --js .` fail trying to resolve `testcontainers-scala-munit_sjs1_3` (404). There is no platform-conditional dependency directive. + - **Verified workaround:** keep JVM-only deps out of directives entirely; mark the tests that need them `//> using target.platform jvm`, and pass the dep on the JVM invocation only: `scala-cli test . --dep com.dimafeng::testcontainers-scala-munit::0.43.6` (passes) while `scala-cli test --js .` stays green. Cost: you can't use single-shot `test --cross` if any platform needs CLI-only deps; run `test .` and `test --js .` as two commands/CI steps. + - Note for dapr4s: plain **Java** deps (single `:`, e.g. `io.dapr:dapr-sdk`) resolve without platform suffix, so they don't break JS *resolution* — but they would be listed in the published `_sjs1_3` POM, which is wrong for consumers. Main-scope JVM-only deps need the same CLI-flag treatment (or a split project layout) for a clean JS artifact. + +## 3. Scala.js options, tests, Node, npm + +Directives (from [reference](https://scala-cli.virtuslab.org/docs/reference/directives/), several verified): + +```scala +//> using jsVersion 1.21.0 // pins scalajs-library; CANNOT exceed the bundled linker (see below) +//> using jsModuleKind es // values: commonjs/common, es/esmodule, none/nomodule +//> using jsEsVersionStr es2017 // verified accepted +//> using jsDom true // or --js-dom flag; uses JSDOMNodeJSEnv +//> using jsEmitWasm true // WebAssembly backend — VERIFIED WORKING +//> using jsMode // also jsHeader, jsEmitSourceMaps, jsSmallModuleForPackage... +``` + +- **Default Scala.js version is fixed per scala-cli release** ([compat table](https://scala-cli.virtuslab.org/docs/guides/advanced/scala-js/)): 1.8.0–1.9.0 → Scala.js 1.19.0; 1.9.1–1.11.0 → 1.20.1; **1.12.0–1.12.5 → 1.20.2; 1.13.0–current (1.14.0) → 1.21.0**. So yes, 1.20.x and 1.21.0 are available. +- **`jsVersion` cannot raise the linker's IR ceiling** (verified): on scala-cli 1.12.2, `//> using jsVersion 1.21.0` still failed with `IRVersionNotSupportedException: ... compiled with Scala.js 1.21 (supported up to: 1.20)`. The linker (scala-js-cli) is pinned per scala-cli release; only same-or-lower jsVersion works. Docs confirm: "In the future, Scala CLI will be able to support any version of Scala.js independently... but for now, there are some limitations". +- **Wasm**: `//> using jsEmitWasm true` + `//> using jsModuleKind es` ran `hello from wasm` successfully on BOTH scala-cli 1.12.2 (Scala.js 1.20.2) and 1.14.0 (1.21.0) with Node 22.20. Feature added in [scala-cli v1.5.2](https://github.com/VirtusLab/scala-cli/releases/tag/v1.5.2) (PR #3255), experimental; backend itself is Scala.js ≥ 1.17.0 experimental ([scala-js wasm docs](https://www.scala-js.org/doc/project/webassembly.html)). Requires ESModule kind. +- **`test --js` runs on Node.js** (plain NodeJSEnv) by default — verified, munit suites execute under Node. With `--js-dom`/`jsDom true` it uses JSDOMNodeJSEnv (jsdom-simulated DOM, still Node). +- **npm module resolution is CWD-based** (verified): scala-cli feeds the launcher to node via **stdin** (failure showed `requireStack: [ '/tmp/[stdin]' ]`), so `require()` resolves against `node_modules` in the **directory you invoke scala-cli from**, NOT the project dir argument and NOT scala-cli's output dir. `npm install jsdom` in the project root + running scala-cli from that root worked (`--js-dom` + mutating `document.title` succeeded); running the same from the parent dir failed; **`NODE_PATH=/path/to/project/node_modules` from elsewhere worked** (verified). Note NODE_PATH only helps CommonJS, not ES modules. scala-cli has no bundler integration ("you'll have to handle bundling yourself" — [Scala.js guide](https://scala-cli.virtuslab.org/docs/guides/advanced/scala-js/)). + +## 4. Scala 3 nightlies + Scala.js + +**Works.** Verified twice with scala-cli 1.14.0: +- `-S 3.nightly` resolved to `3.10.0-RC1-bin-20260609-b34a019-NIGHTLY`, compiled with Scala.js 1.21.0, ran on Node. +- **dapr4s's exact pinned nightly** `//> using scala 3.10.0-RC1-bin-20260607-dec42ae-NIGHTLY` (from `/home/ondra/.t3/worktrees/dapr4s/t3code-c916dd05/project.scala`) compiled for Scala.js 1.21.0 and **passed a munit 1.3.0 + upickle 3.3.1 test on Node**. The JS backend ships inside the Scala 3 compiler; `scala3-library_sjs1_3` exists for nightlies, and `scalajs-library_2.13`/`scalajs-scalalib_2.13` are Scala-3-version-agnostic (a harmless failed probe for a nightly-versioned scalajs-scalalib falls back to the release artifact). + +## 5. Publishing: git:dynver + central + JS + +- `//> using publish.computeVersion git:dynver` worked for the cross publish (exact tag `v0.1.0` → version `0.1.0` for both `_3` and `_sjs1_3` modules). +- `publish.repository central`: scala-cli ≥ **1.8.4** publishes to Maven Central via the **Central Portal's OSSRH Staging API** ([release notes v1.8.4](https://github.com/VirtusLab/scala-cli/releases/tag/v1.8.4)) — required since [OSSRH sunset June 30, 2025](https://central.sonatype.org/pages/ossrh-eol/). dapr4s already publishes this way (`scala-cli publish .` in `.github/workflows/ci.yml` with `PUBLISH_USER/PASSWORD/SECRET_KEY` secrets, scala-cli-setup@v1 installs latest), so switching to `scala-cli --power publish --cross .` is a one-word CI change. +- No open scala-cli issues found specifically about `publish --cross` + Scala.js breakage; the local cross-publish produced clean per-platform POMs (no Gradle module metadata is published — POM only, which is normal for Scala libs). Residual risk: remote Central staging of two modules in one `--cross` run is untested here — verify on first real release (this matches the docs gap: the [publish docs](https://scala-cli.virtuslab.org/docs/commands/publishing/publish/) don't document `--cross`; it's only in `--help-full`). +- `publish local` caveat from docs: "does not currently support publishing of the test scope." + +## 6. Library availability matrix (verified against repo1.maven.org, HTTP codes) + +| Artifact | JVM | Scala.js | +|---|---|---| +| `org.scalameta::munit:1.3.0` | `munit_3` ✓ | `munit_sjs1_3` ✓ (200) — **but its IR is Scala.js 1.21, so JS tests REQUIRE scala-cli ≥ 1.13.0**; on 1.12.x the linker fails with `IRVersionNotSupportedException` (verified both ways) | +| `com.lihaoyi::upickle:3.3.1` | ✓ | `upickle_sjs1_3` ✓ (200, compiled+ran) | +| `com.dimafeng::testcontainers-scala-{munit,core}` | `_3` ✓ | `_sjs1_3` **404 — JVM-only, confirmed** | +| `org.testcontainers:testcontainers` | pure Java ✓ | n/a (no Scala suffix at all) | +| `io.dapr:dapr-sdk*` | pure Java | n/a | + +## 7. GitHub Actions + +Canonical scala-cli job ([cookbook](https://scala-cli.virtuslab.org/docs/cookbooks/introduction/gh-action/)): `actions/checkout` (with `fetch-depth: 0` — required for `git:dynver`) + `coursier/cache-action` + `VirtusLab/scala-cli-setup` (use `with: power: true`), then `scala-cli test .`. For JS jobs: +- ubuntu-latest runners have Node preinstalled; add `actions/setup-node@v4` with `node-version: 22` (or higher) if you need a specific version (recommended for Wasm). +- JS step: `scala-cli test --js .` — no npm setup needed unless you use `--js-dom` or `js.Dynamic.global.require`; in that case `npm ci`/`npm install jsdom` **in the directory the scala-cli command runs from** (cwd-based resolution, see §3). +- `scala-cli publish setup --ci` can generate `.github/workflows/ci.yml` + upload `PUBLISH_USER`, `PUBLISH_PASSWORD`, `PUBLISH_SECRET_KEY`, `PUBLISH_SECRET_KEY_PASSWORD` secrets ([publish-setup docs](https://scala-cli.virtuslab.org/docs/commands/publishing/publish-setup/)) — dapr4s already has the equivalent by hand. + +## Exact recipe that worked end-to-end (scala-cli 1.14.0) + +`project.scala`: +```scala +//> using scala 3.3.6 // or the 3.10 nightly — both verified +//> using platform jvm scala-js // first entry = default platform +//> using dep com.lihaoyi::upickle::3.3.1 +//> using test.dep org.scalameta::munit::1.3.0 +//> using publish.organization io.github.example +//> using publish.name sjs-probe +//> using publish.computeVersion git:dynver +``` +Per-file: `//> using target.platform jvm` / `//> using target.platform scala-js` at the top of platform-specific files. Commands: `scala-cli test .` (+ `--dep` for JVM-only test deps), `scala-cli test --js .`, `scala-cli --power test --cross .` (only if no CLI-only deps), `scala-cli --power publish --cross .`. + +## Sources +- [Directives reference](https://scala-cli.virtuslab.org/docs/reference/directives/) — `platform`/`platforms` grammar, `target.platform`, all `js*` directives +- [Scala.js guide + compat table](https://scala-cli.virtuslab.org/docs/guides/advanced/scala-js/) +- [Publish](https://scala-cli.virtuslab.org/docs/commands/publishing/publish/), [Publish setup](https://scala-cli.virtuslab.org/docs/commands/publishing/publish-setup/) +- [Using directives guide (target directives experimental)](https://scala-cli.virtuslab.org/docs/guides/introduction/using-directives) +- [GH Actions cookbook](https://scala-cli.virtuslab.org/docs/cookbooks/introduction/gh-action/) +- [v1.5.2 release (jsEmitWasm, PR #3255)](https://github.com/VirtusLab/scala-cli/releases/tag/v1.5.2), [v1.8.4 release (Central Portal)](https://github.com/VirtusLab/scala-cli/releases/tag/v1.8.4), [Releases index](https://github.com/VirtusLab/scala-cli/releases) +- Issues: [#1632 target.platform warning/scoping](https://github.com/VirtusLab/scala-cli/issues/1632), [#3590](https://github.com/VirtusLab/scala-cli/issues/3590), [#3591](https://github.com/VirtusLab/scala-cli/issues/3591) +- [OSSRH sunset](https://central.sonatype.org/pages/ossrh-eol/), [Scala.js Wasm backend](https://www.scala-js.org/doc/project/webassembly.html), [Scala.js cross-build (sbt baseline)](https://www.scala-js.org/doc/project/cross-build.html) +- Maven Central directory probes for `munit_sjs1_3/1.3.0`, `upickle_sjs1_3/3.3.1`, `testcontainers-scala-*_sjs1_3` (404) +- Local experiments: `/tmp/sjs-probe` (cross compile/test/publish-local, target.platform split, jsdom/NODE_PATH, dep-leak repro), `/tmp/sjs-nightly`+`/tmp/sjs-nightly2` (nightlies incl. dapr4s's exact pin), `/tmp/sjs-wasm` (jsEmitWasm); dapr4s context from `/home/ondra/.t3/worktrees/dapr4s/t3code-c916dd05/project.scala` and `.github/workflows/ci.yml` + +## VERDICT +Scala CLI fully supports JVM+Scala.js cross-compiling and cross-publishing from one source tree, verified empirically: declare `//> using platform jvm scala-js` (first entry = default platform; non-default selected via `--js`), mark platform-specific files with experimental `//> using target.platform jvm|scala-js` (per-file directive — there is no directory convention), and publish BOTH `_3` and `_sjs1_3` artifacts in a single `scala-cli --power publish --cross .` invocation (verified with `publish local --cross`: correct per-platform POMs/jars, `git:dynver` versioning works; `publish.repository central` works via the Central Portal since scala-cli 1.8.4). JS tests run on Node (jsdom optional via `--js-dom`, with cwd-based node_modules resolution; NODE_PATH works as fallback), `test --cross` runs both platforms in one shot, the Wasm backend works via `//> using jsEmitWasm true` + `jsModuleKind es` (since v1.5.2), and dapr4s's exact Scala 3.10.0-RC1 nightly compiles/tests on Scala.js 1.21.0 with munit 1.3.0 + upickle 3.3.1 (both have `_sjs1_3` artifacts; testcontainers does not). Two hard constraints: munit 1.3.0's JS artifact needs the Scala.js 1.21 linker, i.e. scala-cli >= 1.13.0 (the locally installed 1.12.2 fails with IRVersionNotSupportedException and `jsVersion` cannot override the bundled linker), and dependency directives cannot be platform-scoped (deps in a `target.platform` file still leak into all platforms), so JVM-only deps (testcontainers, io.dapr SDK) must be passed via CLI flags per-invocation or isolated by project layout. + +## BLOCKERS +- scala-cli >= 1.13.0 is required for munit 1.3.0 (and any lib built with Scala.js 1.21 IR) on JS — the locally installed 1.12.2 ships a Scala.js 1.20.2 linker that fails with IRVersionNotSupportedException, and //> using jsVersion cannot raise the bundled linker's IR ceiling (upgrade scala-cli; CI's scala-cli-setup@v1 already installs latest so only local installs are affected) +- No platform-scoped dependency directives exist: using dep / test.dep declared in a //> using target.platform file still apply to ALL platforms (verified failure resolving testcontainers-scala-munit_sjs1_3). JVM-only deps must be passed as CLI flags on JVM-only invocations or isolated via project layout — for dapr4s this affects testcontainers test deps AND the main-scope io.dapr Java SDK deps, which would otherwise pollute the published _sjs1_3 POM. This also means single-shot 'test --cross' can't be used when any platform needs CLI-only deps. \ No newline at end of file diff --git a/raw/scala-js/2026-06-11-scalajs-async-jspi.md b/raw/scala-js/2026-06-11-scalajs-async-jspi.md new file mode 100644 index 0000000..2ae2c22 --- /dev/null +++ b/raw/scala-js/2026-06-11-scalajs-async-jspi.md @@ -0,0 +1,114 @@ +# Exposing a synchronous-looking blocking API on Scala.js (js.async/js.await, JSPI, Wasm backend) — research for dapr4s + +> Source: scala-js.org release notes (1.17.0, 1.19.0, 1.20.1, 1.21.0) and WebAssembly backend docs; scala-js/scala-js JSPI.scala; chromestatus.com; nodejs/node#60014 + Node 25.0.0 release post; un-ts/synckit; typelevel/cats-effect#529; VirtusLab/scala-cli v1.5.2 release +> Collected: 2026-06-11 +> Published: Unknown + +Context: dapr4s today is a direct-style sync library (`def get(key): Option[T]`) that parks **virtual threads** via `CompletableFuture.get()` (`/home/ondra/.t3/worktrees/dapr4s/t3code-c916dd05/src/Dapr.scala`, `src/internal/MonoOps.scala`), plus an **inbound** app server for pubsub/actor/binding callbacks (`src/internal/DaprAppServer.scala`). The question is what can replicate "looks blocking, doesn't block" on Scala.js, where the Dapr JS SDK returns `js.Promise`. + +--- + +## 1. `scala.scalajs.js.async` / `js.await` + +- **Introduced in Scala.js 1.19.0** (announced 2025-04-21). `js.async { ... }` returns a `js.Promise[A]`; semantics are exactly an immediately-invoked JS async function: `(async () => body)()`. `js.await(p: js.Promise[A]): A` resumes when the promise resolves (or throws on rejection). Source: [Announcing Scala.js 1.19.0](https://www.scala-js.org/news/2025/04/21/announcing-scalajs-1.19.0/). +- **Where `js.await` is allowed (JS backend):** only **lexically/directly inside the `js.async` block** — it may appear in conditional branches, `while` loops and `try/catch/finally`, but **not** nested in any local method, local class, by-name argument, or closure/lambda (which rules out `for`-comprehensions and `.map(...)` bodies). So on the plain JS backend `js.await` does **not** cross function boundaries — same colored-function model as JS itself. +- **Requirement:** the linker must target **ES2017+**: `scalaJSLinkerConfig ~= (_.withESFeatures(_.withESVersion(ESVersion.ES2017)))`. +- **Scala versions:** Scala 2.12/2.13 got it with Scala.js 1.19.0; **Scala 3 needed Scala 3.8.0**, which bundles Scala.js 1.20.1 ("Scala 3.8.0 upgraded to Scala.js 1.20.1 and added support for js.async and js.await, including JSPI on Wasm"); 3.8.0 shipped a runtime regression — use 3.8.1+. dapr4s is on a 3.10.0-RC1 nightly (`project.scala`), so this is available. Sources: [Scala.js 1.19.0 notes](https://www.scala-js.org/news/2025/04/21/announcing-scalajs-1.19.0/), [scala3 3.8.0 release notes](https://newreleases.io/project/github/scala/scala3/release/3.8.0). +- **Orphan `js.await`:** a `js.await` that is *not* directly inside a `js.async` block. Enabled by `import scala.scalajs.js.wasm.JSPI.allowOrphanJSAwait` (an `implicit object ... extends js.AwaitPermit`). The scaladoc is explicit: *"The resulting code will then only link when targeting WebAssembly."* — i.e., **on the plain JS backend, orphan awaits are a link-time error**, not a runtime one. On Wasm they are validated **at run time**: there must be a dynamically enclosing `js.async { ... }` on the call stack **with no JavaScript frame between it and the `js.await`**; otherwise a `WebAssembly.SuspendError` is thrown. Sources: [1.19.0 notes](https://www.scala-js.org/news/2025/04/21/announcing-scalajs-1.19.0/), [JSPI.scala in scala-js repo](https://github.com/scala-js/scala-js/blob/main/library/src/main/scala/scala/scalajs/js/wasm/JSPI.scala). + +**Answer to the key question:** on the **JS backend**, `js.await` is strictly local to its `js.async`. Only on the **Wasm backend with JSPI** can a deep call stack of ordinary Scala methods transparently suspend — that is exactly the "new superpower offered by JSPI" the release notes advertise: *"as long as you enter into a `js.async` block somewhere, you can synchronously await Promises in any arbitrary function."* The one hard caveat: any **JS frame** in between (a Scala lambda passed to a JS API — `Promise.then`, timers, an Express/HTTP-server handler, `js.Array.map`) breaks suspension with `WebAssembly.SuspendError`; you must re-enter `js.async` inside every JS-invoked callback. + +## 2. WebAssembly backend status & settings + +- **Since Scala.js 1.17.0** (2024-09-28): experimental Wasm backend; enable with `scalaJSLinkerConfig ~= (_.withExperimentalUseWebAssembly(true).withModuleKind(ModuleKind.ESModule))`. **`ModuleKind.ESModule` is mandatory**, as is `ModuleSplitStyle.FewestModules` (single module only — no `js.dynamicImport`, no `@JSExportTopLevel` across multiple modules). `link`, `run` and `test` from sbt all work. Source: [Announcing Scala.js 1.17.0](https://www.scala-js.org/news/2024/09/28/announcing-scalajs-1.17.0/). +- **1.18.0 and 1.20.0 were broken and never announced** (superseded by 1.18.1, Jan 2025, and 1.20.1, Sep 2025). **1.19.0** (Apr 2025) added JSPI/orphan-await support and made Wasm output ~15% faster (geomean) than JS output on their benchmarks; **1.20.1** improved Wasm debugging info (names of types/fields/locals/globals) and perf (varargs, collections, startup); **1.20.2** (Jan 2026) is a bugfix release. **1.21.0** (Apr 2026) does not change the experimental status. Sources: [news index](https://www.scala-js.org/news/index.html), [1.20.1 notes](https://www.scala-js.org/news/2025/09/06/announcing-scalajs-1.20.1/), [1.21.0 notes](http://www.scala-js.org/news/2026/04/04/announcing-scalajs-1.21.0/). +- **Still experimental as of 1.21.0**: the docs warn it "may be removed in future minor versions" and may require newer Wasm engines in minor releases. Source: [Experimental WebAssembly backend](https://www.scala-js.org/doc/project/webassembly.html). +- **JSPI answer:** yes — with the Wasm backend + JSPI + `allowOrphanJSAwait`, `js.await` suspends **across arbitrary Scala function boundaries** (deep ordinary call stacks), subject to the no-intervening-JS-frame rule above. Exact names: linker setting **`withExperimentalUseWebAssembly(true)`** (since 1.17.0); import **`scala.scalajs.js.wasm.JSPI.allowOrphanJSAwait`** (since 1.19.0). There is **no** linker flag named `allowOrphanJSAwait` — it's a source-level implicit import; the gate is the Wasm-only link check. + +### Restrictions of the Wasm backend (question 4) +- Semantics: *"The Wasm backend is nothing but an alternative backend for the Scala.js language. Its semantics are the same as Scala.js-on-JS"* — the **javalib/JDK API coverage is the same** as the JS backend; there is no extra list of unsupported JDK APIs. +- **`@JSExport` / `@JSExportAll` are silently ignored** (JS cannot call methods on Scala instances; no `toString` interop on instances). `@JSExportTopLevel` works (single module). +- ESModule-only, single module, JS host required (not standalone WASI). +- Performance: ~**30% lower run time** than the JS output on their benchmarks for compute-heavy code (interop-heavy code can be slower); **code size ~2× the JS backend in fullLink mode**. +- **Testing:** `sbt test` works under the Wasm backend (stated since the 1.17.0 announcement: you can link, run and *test*); munit is an ordinary sbt test framework over the standard Scala.js test bridge (which uses top-level exports), so munit works — provided the jsEnv node flags below. +Sources: [webassembly doc page](https://www.scala-js.org/doc/project/webassembly.html), [1.17.0 notes](https://www.scala-js.org/news/2024/09/28/announcing-scalajs-1.17.0/). + +## 3. JSPI runtime support + +- **Spec status:** JSPI reached W3C Wasm CG **stage 4 (standardized) in April 2025**. Source: [chromestatus 5674874568704000](https://chromestatus.com/feature/5674874568704000), [Intent to Ship thread](https://groups.google.com/a/chromium.org/g/blink-dev/c/w_jCD4gf7Bc). +- **Chrome:** origin trial Chrome 123–136; **shipped by default in Chrome 137** (May 2025). The Scala.js docs list **Chrome 137+ as "full support"** (covers both exnref exception handling and JSPI). (Fun datapoint: [JSPI shipping in Chrome 137 broke urllib3/Pyodide CI](https://github.com/urllib3/urllib3/issues/3598).) +- **Firefox:** Fx 131+ has all required Wasm features for the Scala.js Wasm backend; for JSPI the Scala.js docs still say you must flip `javascript.options.wasm_js_promise_integration` in about:config, while the Chrome intent-to-ship thread states JSPI "is currently shipping in Chrome and in Firefox" — treat Firefox-default-on as recent/uncertain. +- **Safari: no JSPI at all** (18.4+ runs the Wasm backend but *not* `js.async`/orphan-await code). +- **Node.js:** + - Node 22: **no JSPI** (Scala.js docs require Node 23+ even for the flag; Node 22 also needs `--experimental-wasm-exnref` just for the backend itself). + - Node 23 / **Node 24** (V8 13.6): JSPI available **behind `--experimental-wasm-jspi`** — "Node 24 was released just before the JSPI feature gate was removed from V8" ([nodejs/node#60014](https://github.com/nodejs/node/pull/60014)). + - **Node 25** (2025-10-15, V8 14.1): **JSPI enabled by default** — the explicit enable commit was even reverted because the V8 upgrade had already turned it on ([nodejs/node#60014](https://github.com/nodejs/node/pull/60014), [Node 25.0.0 release post](https://nodejs.org/en/blog/release/v25.0.0)). + - Recommended sbt jsEnv (from the Scala.js docs, needed for Node 23/24): `new NodeJSEnv(NodeJSEnv.Config().withArgs(List("--experimental-wasm-exnref", "--experimental-wasm-jspi", "--experimental-wasm-imported-strings", "--turboshaft-wasm")))`. +- **CI on GitHub Actions:** *plain* Node 22 — **not usable** for JSPI. Usable today with `actions/setup-node` + `node-version: 24` **plus the flags** (passed via the jsEnv args; note `NODE_OPTIONS` does **not** allow V8 `--experimental-wasm-*` flags, so the runner must spawn node with argv flags), or simply **Node 25/26 where it's on by default**. So yes — CI is realistic today, but not with the runner-default Node untouched on Node ≤24. + +## 4. (folded into §2 above) + +## 5. Alternative patterns for sync-looking APIs on the plain JS backend + +### What ecosystem libraries do +- **cats-effect:** `IO#unsafeRunSync()` **throws on Scala.js** ("cannot synchronously await result on JavaScript") the moment it hits an async boundary; JS users get `unsafeRunAsync`/`unsafeToPromise`/`Dispatcher`, and `SyncIO` exists precisely as the "guaranteed-no-async-boundary" sync subset. Sources: [typelevel/cats-effect#529](https://github.com/typelevel/cats-effect/issues/529), [#2846](https://github.com/typelevel/cats-effect/pull/2846). +- **sttp:** the synchronous backends (`DefaultSyncBackend`) are JVM-only; JS gets `FetchBackend` returning `Future`. The general pattern is **abstract over the effect (`F[_]`)** or **platform-split source trees** ("PlatformCompat"): same method names, platform-specific return type (`Identity` on JVM, `Future`/`js.Promise` on JS) — which is an API change in disguise, since shared user code can't treat `Option[T]` and `Future[Option[T]]` uniformly. + +### Synchronously waiting for a Promise on plain JS — honest assessment +On a single-threaded JS engine this is **impossible in-thread by design**: run-to-completion means the continuation that would resolve the Promise can never run while you spin/block. The only real escape hatches: + +1. **deasync-style event-loop re-entry (N-API/`uv_run`)** — the `deasync` npm package pumps libuv from native code. It's a node-gyp native addon, notoriously fragile across Node versions, can re-enter user code at arbitrary points (reentrancy bugs), and benchmarks ~100× slower than native (synckit's own benchmark: deasync 1367 ms vs synckit 160 ms vs native 13 ms on Node 20). **Not shippable** as a dependency of a Scala.js library. +2. **`Atomics.wait` + worker_threads + SharedArrayBuffer + `receiveMessageOnPort` (the synckit pattern)** — **this genuinely works in Node**. Key facts: `Atomics.wait` **can block the Node.js main thread** (it's only forbidden on the *browser* main thread, where it throws `TypeError`; browser *workers* may block). The pattern: main thread creates a `MessageChannel` + `SharedArrayBuffer`, posts the request to a persistent worker thread; the worker runs the async work (e.g. the `@dapr/dapr` client call) on *its own* event loop, posts the result on the port, `Atomics.notify`s; the main thread wakes from `Atomics.wait` and reads the result **synchronously** via `worker_threads.receiveMessageOnPort`. This is exactly what **[synckit](https://github.com/un-ts/synckit)** (`createSyncFn` / `runAsWorker`) ships, used in production by **eslint-plugin-prettier, eslint-plugin-cspell, eslint-plugin-mdx, Jest snapshot tooling** — proof the pattern is production-grade for *tooling*. Prior art also: [Sam Thorogood's write-up](https://samthor.au/2021/block-nodejs-main-thread/), [jimmywarting/await-sync](https://github.com/jimmywarting/await-sync), Anna Henningsen's [synchronous-worker](https://www.npmjs.com/package/synchronous-worker). + **Could dapr4s ship this?** Technically yes: pure-JS deps (no native code), Node-only; the stateful `DaprClient` would live in the persistent worker (synckit keeps one worker per `createSyncFn`), with the worker script either hand-written JS or a second Scala.js-compiled module. Real costs: (a) **it blocks the entire Node event loop for the duration of every Dapr call** — server concurrency collapses to fully serial; (b) **deadlock class**: dapr4s apps *receive* callbacks from the sidecar (pubsub, actors, bindings via `DaprAppServer`); if a blocking outbound call's completion ever depends on the blocked main thread serving an inbound request (actor reentrancy, workflow signals), you deadlock; (c) arguments/results must be **structured-clone serializable** — fine for Dapr's JSON-ish payloads, bad for streams; streaming subscriptions can't cross a one-shot sync bridge; (d) per-call overhead ~0.1–1 ms + serialization (synckit ~12× native in its microbenchmark — acceptable next to a sidecar network hop); (e) the worker file must survive consumers' bundlers; (f) **Node-only** (no browser main thread) — acceptable for Dapr, which is server-side anyway. +3. **Busy-wait / microtask draining** — does not exist as a primitive in JS; not an option. + +## 6. scala-cli support + +- **`//> using jsEmitWasm`** directive and **`--js-emit-wasm`** flag exist **since scala-cli v1.5.2** (experimental, `--power`; "non-ideal user experience should be expected"). Required combo: `//> using platform js`, `//> using jsEmitWasm`, `//> using jsModuleKind es`, `//> using jsModuleSplitStyleStr fewestmodules`. Sources: [scala-cli v1.5.2 release](https://github.com/VirtusLab/scala-cli/releases/tag/v1.5.2), [directives reference](https://scala-cli.virtuslab.org/docs/reference/directives/). +- The v1.5.2 notes only demonstrate **`package`** ("Wrote .../main.js, run it with node ./wasm.js/main.js" — you run node yourself). Crucially, **scala-cli has no documented way to pass Node argv flags (`--experimental-wasm-exnref`, `--experimental-wasm-jspi`) to the node process it spawns for `run`/`test`**, and these flags are not accepted via `NODE_OPTIONS`. Practical consequence for dapr4s (a scala-cli project with munit): Wasm tests under scala-cli are only realistic on **Node 25+** (flags default-on) — and even `run`/`test`-on-wasm under scala-cli should be verified empirically; the supported path for Wasm testing today is **sbt** (full `jsEnv := new NodeJSEnv(NodeJSEnv.Config().withArgs(...))` control) or Mill's `jsEnvConfig`. + +--- + +## Final viability assessment (for a LIBRARY) + +### (a) Wasm + JSPI (orphan `js.await`) +**The only mechanism in existence that preserves dapr4s's direct-style `def get(key): Option[T]` on the JS platform without blocking the event loop** — JSPI *suspends* the Wasm stack, the event loop keeps running, so inbound Dapr callbacks still get served. Architecturally it is the exact analogue of virtual-thread parking. Published artifacts are even backend-neutral (`.sjsir`), so one `_sjs1` artifact works — orphan awaits simply fail **at link time** for plain-JS-backend consumers. The costs: consumers are forced onto an **experimental** backend (explicitly removable in future minors), ESModule-only, single-module, `@JSExport`-less, 2× code size; runtime floor is Node 23/24-with-flags or **Node 25+/Chrome 137+ clean, no Safari**; every JS-invoked callback (Dapr JS SDK server handlers!) must re-enter `js.async`, and any stray Scala-lambda-through-JS-API frame turns into a runtime `WebAssembly.SuspendError`; scala-cli test support is shaky (sbt/Mill needed, or Node 25+). **Verdict: viable as an explicitly experimental, opt-in target ("dapr4s on Wasm") — a great demo and a plausible future; not viable in 2026 as the default JS story a library imposes on all users.** + +### (b) `Atomics.wait`/synckit-style sync bridge on plain JS +Real, proven (synckit powers eslint-plugin-prettier et al.), pure-JS, shippable — **but only for Node, and it hard-blocks the event loop per call**. For dapr4s's bidirectional model (sidecar calls back into the app for pubsub/actors/workflows) that means serialized throughput at best and genuine deadlocks at worst, plus no streaming and structured-clone-only payloads. **Verdict: viable only for a narrow outbound-only client subset (state get/set, invoke, publish from scripts/CLI tools); not viable as the general dapr4s-on-JS architecture. If pursued, scope it to a clearly-labeled `dapr4s-sync-node` client module.** + +### (c) API change (async on JS) +The boring, robust, ecosystem-standard answer (cats-effect forbids `unsafeRunSync` on JS; sttp's sync backends are JVM-only). Platform-split sources giving JS users `Future`/`js.Promise` return types (or a `Dapr().runAsync { ... }` whose body returns within `js.async`, using plain non-orphan `js.await` at the call sites — Scala 3.8+/SJS 1.19+ make that direct-style-*ish*) works on the stable JS backend, every Node/browser version, scala-cli, munit, today. The cost is product-level: dapr4s's signature feature — colorless direct style — does not survive on plain JS; shared user code can't be written once against both signatures. **Verdict: the only production-grade option for the plain JS backend; recommended as the JS baseline, optionally paired with (a) as an experimental Wasm target sharing the same direct-style sources via a small `Awaiter` abstraction (JVM: `CompletableFuture.get()` on a virtual thread; Wasm: orphan `js.await`; plain JS: link-error/unsupported).** + +### Sources +- https://www.scala-js.org/news/2025/04/21/announcing-scalajs-1.19.0/ +- https://www.scala-js.org/doc/project/webassembly.html +- https://www.scala-js.org/news/2024/09/28/announcing-scalajs-1.17.0/ +- https://www.scala-js.org/news/2025/09/06/announcing-scalajs-1.20.1/ +- http://www.scala-js.org/news/2026/04/04/announcing-scalajs-1.21.0/ +- https://github.com/scala-js/scala-js/blob/main/library/src/main/scala/scala/scalajs/js/wasm/JSPI.scala +- https://chromestatus.com/feature/5674874568704000 +- https://developer.chrome.com/release-notes/137 +- https://github.com/urllib3/urllib3/issues/3598 +- https://github.com/nodejs/node/pull/60014 +- https://nodejs.org/en/blog/release/v25.0.0 +- https://v8.dev/blog/jspi +- https://github.com/un-ts/synckit +- https://samthor.au/2021/block-nodejs-main-thread/ +- https://github.com/jimmywarting/await-sync +- https://github.com/typelevel/cats-effect/issues/529 +- https://github.com/typelevel/cats-effect/pull/2846 +- https://github.com/VirtusLab/scala-cli/releases/tag/v1.5.2 +- https://scala-cli.virtuslab.org/docs/reference/directives/ +- https://newreleases.io/project/github/scala/scala3/release/3.8.0 + +## VERDICT +On the plain Scala.js backend there is no sound way to expose dapr4s's blocking-looking `def get(key): Option[T]` — js.await (Scala.js 1.19.0+, ES2017+, Scala 3.8+) only works lexically inside js.async there, and the only true sync-wait hack (synckit-style worker_threads + SharedArrayBuffer + Atomics.wait + receiveMessageOnPort, proven by eslint-plugin-prettier) is Node-only, blocks the whole event loop per call, and risks deadlock with Dapr's sidecar-callback model. The one mechanism that genuinely preserves direct style is the experimental WebAssembly backend (withExperimentalUseWebAssembly(true), ESModule-only) with JSPI: importing scala.scalajs.js.wasm.JSPI.allowOrphanJSAwait lets js.await suspend across arbitrary Scala frames (link-error on plain JS, WebAssembly.SuspendError if a JS frame intervenes), running flag-free on Node 25+ and Chrome 137+ (Node 23/24 need --experimental-wasm-jspi; Safari unsupported; scala-cli's jsEmitWasm is package-grade only, so tests need sbt/Mill or Node 25+). Recommended posture for a library: ship an async-on-JS API (the cats-effect/sttp precedent) as the stable JS baseline, and optionally offer the direct-style API as an explicitly experimental Wasm+JSPI target via a small platform Awaiter (JVM virtual-thread park / Wasm orphan js.await) — do not bet the default JS story on either the experimental backend or an Atomics.wait bridge. + +## BLOCKERS +- Plain JS backend fundamentally cannot block on a Promise in-thread: js.await is restricted to lexically-enclosing js.async blocks; orphan js.await is a hard link-time error when targeting JavaScript (scala.scalajs.js.wasm.JSPI.allowOrphanJSAwait scaladoc: 'will then only link when targeting WebAssembly') +- Wasm backend is officially experimental as of Scala.js 1.21.0 (may be removed/require newer engines in minor releases), ignores @JSExport, ESModule + single module only, ~2x code size — a library cannot impose it as the default JS target +- JSPI runtime floor: Node 25+/Chrome 137+ for flag-free operation; Node 23/24 require --experimental-wasm-jspi (not settable via NODE_OPTIONS); Safari has no JSPI at all +- scala-cli (dapr4s's build tool) supports jsEmitWasm for package only and has no documented way to pass Node flags to its run/test node process — Wasm test runs need sbt/Mill jsEnv config or Node 25+, and scala-cli run/test-on-wasm remains unverified +- Atomics.wait sync-bridge blocks the entire Node event loop per call and can deadlock dapr4s's bidirectional model (sidecar calling back into the app's pubsub/actor/binding handlers while the main thread is blocked); streaming APIs cannot cross the bridge; Node-only +- Wasm+JSPI suspension breaks across JS frames: every JS-invoked callback (e.g. Dapr JS SDK server handlers, Scala lambdas passed to JS APIs) needs its own js.async re-entry or it throws WebAssembly.SuspendError at runtime \ No newline at end of file diff --git a/scripts/embed-st-facades.sh b/scripts/embed-st-facades.sh new file mode 100755 index 0000000..810af29 --- /dev/null +++ b/scripts/embed-st-facades.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Stage the ScalablyTyped facade classes for EMBEDDING into the published dapr4s_sjs1_3 jar. +# +# WHY: js-deps.scala declares the org.scalablytyped facades as compileOnly deps, so the +# published POM never references the ivy-local-only org.scalablytyped coordinates — but the +# linker still needs the facade .sjsir at consumer link time. This script unpacks the +# class/tasty/sjsir entries of EXACTLY the org.scalablytyped jars the three facade roots +# transitively require into a staging directory, which the publish invocation then packs into +# the jar: +# +# scripts/embed-st-facades.sh +# scala-cli --power publish --js . --resource-dirs .scala-build/st-embed +# +# The embedded classes live in the dapr4s-specific `dapr4styped.*` package (see +# scripts/generate-st-facades.sh for the rename rationale), so they cannot collide with a +# consumer's own ScalablyTyped generation, which always emits `typings.*`. +# +# The transitive set is computed by coursier from the root coordinates pinned in js-deps.scala +# (the single source of truth — no digest copy here), NOT by globbing +# ~/.ivy2/local/org.scalablytyped: that directory accumulates stale artifacts from older +# digests/generations, and a glob would sweep them in. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +JS_DEPS="${REPO_ROOT}/js-deps.scala" +STAGING="${REPO_ROOT}/.scala-build/st-embed" + +command -v cs >/dev/null || { echo "ERROR: coursier ('cs') is required on PATH." >&2; exit 1; } + +# --- Root coordinates: read from js-deps.scala --------------------------------------------------- +# `org.scalablytyped::name::ver` (scala-cli cross syntax) -> `org.scalablytyped:name_sjs1_3:ver` +# (raw coursier coordinates; the ST jars are always _sjs1_3). +mapfile -t roots < <( + grep -oE 'using compileOnly\.dep "org\.scalablytyped::[^"]+"' "${JS_DEPS}" \ + | sed -E 's/.*"org\.scalablytyped::([^:]+)::([^"]+)"/org.scalablytyped:\1_sjs1_3:\2/' +) +if [[ "${#roots[@]}" -ne 3 ]]; then + echo "ERROR: expected exactly 3 org.scalablytyped compileOnly deps in js-deps.scala, found ${#roots[@]}." >&2 + exit 1 +fi +echo "Facade roots (from js-deps.scala):" +printf ' %s\n' "${roots[@]}" + +# --- Resolve the exact transitive jar set --------------------------------------------------------- +# cs fetch resolves ivy2Local (where generate-st-facades.sh published) plus Maven Central (for +# scala-library/scalajs-library/scalablytyped-runtime/scalajs-dom). Only the org.scalablytyped +# jars get embedded; the Central-hosted ones stay ordinary POM dependencies (js-deps.scala +# declares the two the facades need at link time: scalablytyped-runtime, scalajs-dom). +mapfile -t st_jars < <( + cs fetch --repository ivy2Local --repository central "${roots[@]}" \ + | grep -E '/org\.scalablytyped/|/org/scalablytyped/' +) +if [[ "${#st_jars[@]}" -lt 3 ]]; then + echo "ERROR: coursier resolved only ${#st_jars[@]} org.scalablytyped jars — run scripts/generate-st-facades.sh first." >&2 + exit 1 +fi + +# --- Unpack class/tasty/sjsir entries into the staging dir ---------------------------------------- +# Everything else (META-INF/MANIFEST.MF is the ST jars' only other content) is excluded by the +# include patterns: the dapr4s jar must carry no manifest junk beyond its own. The ST jars place +# all their classes under the renamed `dapr4styped/` package root — verified below, so a +# converter change that starts emitting other roots (or a class/tasty/sjsir file under META-INF) +# fails loudly instead of silently polluting the dapr4s jar namespace. +rm -rf "${STAGING}" +mkdir -p "${STAGING}" +for jar in "${st_jars[@]}"; do + unzip -qo "${jar}" '*.class' '*.tasty' '*.sjsir' -d "${STAGING}" +done + +unexpected="$(find "${STAGING}" -mindepth 1 -maxdepth 1 ! -name 'dapr4styped' -print)" +if [[ -n "${unexpected}" ]]; then + echo "ERROR: staged entries outside the dapr4styped/ package root:" >&2 + echo "${unexpected}" >&2 + exit 1 +fi + +echo "Embedded ${#st_jars[@]} ScalablyTyped jars into ${STAGING}:" +echo " $(find "${STAGING}" -name '*.sjsir' | wc -l) .sjsir, $(find "${STAGING}" -name '*.class' | wc -l) .class, $(find "${STAGING}" -name '*.tasty' | wc -l) .tasty files under dapr4styped/" diff --git a/scripts/generate-st-facades.sh b/scripts/generate-st-facades.sh new file mode 100755 index 0000000..6ed0e18 --- /dev/null +++ b/scripts/generate-st-facades.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Generate the ScalablyTyped facades for the dapr4s Scala.js layer. +# +# Converts the TypeScript type definitions of the npm packages pinned in package.json +# (@dapr/dapr, @types/express, @types/node) into Scala.js facade jars and publishes them to +# the LOCAL ivy repository (~/.ivy2/local/org.scalablytyped/...). js-deps.scala references the +# resulting coordinates as compile-only deps; nothing is committed and nothing is published +# remotely. Run this once per machine (developer or CI) before the first +# `scala-cli compile --js .`, and again whenever the digests change. These ivy-local jars are +# needed only for BUILDING dapr4s itself — at publish time scripts/embed-st-facades.sh embeds +# their classes into the dapr4s_sjs1_3 jar, so dapr4s consumers never run this script. +# +# THE PINNED CONVERTER TUPLE — the digests below are deterministic in exactly these inputs: +# * package-lock.json contents (i.e. the pinned npm versions in package.json), +# * the converter version (CONVERTER_VERSION), +# * the converter flags (--scala, --scalajs, -s / the enabled standard libs, +# --outputPackage — yes, the output package name is digest-relevant like every other flag). +# Changing ANY of them changes the digests; after a regeneration, update both js-deps.scala and +# the EXPECTED_* variables here (single source of truth check below fails loudly on drift). +set -euo pipefail + +CONVERTER_VERSION="1.0.0-beta45" +SCALA_VERSION="3.3.6" # ST publishes for 3.x; any Scala 3 build (incl. nightlies) consumes _sjs1_3 jars +SCALAJS_VERSION="1.21.0" +STDLIB="es2022" +# The generated facade classes are EMBEDDED into the published dapr4s_sjs1_3 jar +# (scripts/embed-st-facades.sh), so they must NOT live in ScalablyTyped's default `typings.*` +# package: a downstream project that runs its own ScalablyTyped generation (which always emits +# `typings.*`, including its own `typings.std`/`typings.node`) would collide with our embedded +# classes at link time. A dapr4s-specific root package keeps the embedded tree disjoint from +# anything a consumer can generate. It must be a SINGLE identifier: the converter's +# --outputPackage is parsed as one `Name` (Main.scala: `Name(x)`), so a dotted value like +# "dapr4s.typings" would be backtick-escaped into one bizarre identifier, not a nested package. +# --organization stays at the default org.scalablytyped: the ivy-local coordinates are +# compile-time-only for building dapr4s itself and never reach consumers. +OUTPUT_PACKAGE="dapr4styped" + +# The expected coordinates (npmVersion-digest), kept in lockstep with js-deps.scala. +EXPECTED_DAPR="3.18.0-d3e034" +EXPECTED_EXPRESS="4.17.21-8ee06b" +EXPECTED_NODE="22.13.0-e98bda" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IVY_LOCAL="${HOME}/.ivy2/local/org.scalablytyped" +JS_DEPS="${REPO_ROOT}/js-deps.scala" + +# --- Guard: js-deps.scala must agree with the digests pinned here ------------------------------- +for entry in "dapr__dapr::${EXPECTED_DAPR}" "express::${EXPECTED_EXPRESS}" "node::${EXPECTED_NODE}"; do + if ! grep -qF "org.scalablytyped::${entry}" "${JS_DEPS}"; then + echo "ERROR: js-deps.scala does not pin org.scalablytyped::${entry}." >&2 + echo " The digest variables in $(basename "$0") and js-deps.scala have drifted apart;" >&2 + echo " update them together (see the digest-update procedure in js-deps.scala)." >&2 + exit 1 + fi +done + +# --- Skip when the artifacts are already materialised -------------------------------------------- +# All three root jars must exist, not just one: an interrupted converter run can leave ivy2Local +# partially populated, and a single-jar marker would then no-op forever while `compile --js` +# keeps failing on the missing org.scalablytyped deps. +all_present=1 +for spec in "dapr__dapr_sjs1_3:${EXPECTED_DAPR}" "express_sjs1_3:${EXPECTED_EXPRESS}" "node_sjs1_3:${EXPECTED_NODE}"; do + artifact="${spec%%:*}"; version="${spec##*:}" + [[ -f "${IVY_LOCAL}/${artifact}/${version}/jars/${artifact}.jar" ]] || all_present=0 +done +if [[ "${all_present}" -eq 1 ]]; then + echo "ScalablyTyped facades already present in ${IVY_LOCAL}; nothing to do." + exit 0 +fi + +# --- Preconditions ------------------------------------------------------------------------------- +command -v cs >/dev/null || { echo "ERROR: coursier ('cs') is required on PATH." >&2; exit 1; } +if [[ ! -d "${REPO_ROOT}/node_modules/typescript" ]]; then + echo "node_modules/typescript missing — running npm install (the converter needs the typescript package)..." + (cd "${REPO_ROOT}" && npm install) +fi + +# --- Convert ------------------------------------------------------------------------------------- +# The converter reads package.json/package-lock.json/node_modules from the working directory and +# publishes every converted package (the three roots plus their type-level dependencies) to +# ivy2Local. @types/express and @types/node are conversion roots because they are top-level +# *dependencies* in package.json (devDependencies are not converted). +# +# It also drops its generated .scala sources into ./out of the working directory; that scratch +# tree MUST NOT survive (scala-cli would compile it as project sources), so it is removed when the +# converter exits — the published ivy2Local jars are the only output that matters. +echo "Running ScalablyTyped converter ${CONVERTER_VERSION} (scala ${SCALA_VERSION}, scalajs ${SCALAJS_VERSION}, stdlib ${STDLIB}, outputPackage ${OUTPUT_PACKAGE})..." +trap 'rm -rf "${REPO_ROOT}/out"' EXIT +(cd "${REPO_ROOT}" && cs launch "org.scalablytyped.converter:cli_3:${CONVERTER_VERSION}" -- \ + --scala "${SCALA_VERSION}" --scalajs "${SCALAJS_VERSION}" -s "${STDLIB}" \ + --outputPackage "${OUTPUT_PACKAGE}") + +# --- Verify the expected digests came out -------------------------------------------------------- +status=0 +for spec in "dapr__dapr_sjs1_3:${EXPECTED_DAPR}" "express_sjs1_3:${EXPECTED_EXPRESS}" "node_sjs1_3:${EXPECTED_NODE}"; do + artifact="${spec%%:*}"; version="${spec##*:}" + jar="${IVY_LOCAL}/${artifact}/${version}/jars/${artifact}.jar" + if [[ -f "${jar}" ]]; then + echo "OK org.scalablytyped:${artifact}:${version}" + else + echo "ERROR: expected ${jar} after conversion, but it does not exist." >&2 + echo " Produced versions for ${artifact}:" >&2 + ls "${IVY_LOCAL}/${artifact}/" >&2 || true + echo " The digest changed — update js-deps.scala and the EXPECTED_* variables together." >&2 + status=1 + fi +done +exit "${status}" diff --git a/scripts/it/components/configstore.yaml b/scripts/it/components/configstore.yaml new file mode 100644 index 0000000..c84c508 --- /dev/null +++ b/scripts/it/components/configstore.yaml @@ -0,0 +1,14 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: configstore +spec: + type: configuration.redis + version: v1 + metadata: + # See statestore.yaml for the ${DAPR4S_IT_REDIS_HOST} substitution. Configuration items are + # seeded by `redis-cli MSET` (both harnesses); Dapr's redis config store splits "value||version". + - name: redisHost + value: ${DAPR4S_IT_REDIS_HOST} + - name: redisPassword + value: "" diff --git a/scripts/it/components/cryptostore.yaml b/scripts/it/components/cryptostore.yaml new file mode 100644 index 0000000..e3fb016 --- /dev/null +++ b/scripts/it/components/cryptostore.yaml @@ -0,0 +1,13 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: cryptostore +spec: + type: crypto.dapr.localstorage + version: v1 + metadata: + # Directory of PEM keys as seen INSIDE the daprd container. Both harnesses mount the + # rendered component dir's sibling key material at /dapr4s-it; the keys/ subdir holds the + # RSA key `rsa-key`, generated fresh on every harness `up` (git-ignored). + - name: path + value: /dapr4s-it/keys diff --git a/scripts/it/components/lockstore.yaml b/scripts/it/components/lockstore.yaml new file mode 100644 index 0000000..4d45ee1 --- /dev/null +++ b/scripts/it/components/lockstore.yaml @@ -0,0 +1,13 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: lockstore +spec: + type: lock.redis + version: v1 + metadata: + # See statestore.yaml for the ${DAPR4S_IT_REDIS_HOST} substitution. + - name: redisHost + value: ${DAPR4S_IT_REDIS_HOST} + - name: redisPassword + value: "" diff --git a/scripts/it/components/pubsub.yaml b/scripts/it/components/pubsub.yaml new file mode 100644 index 0000000..1746e19 --- /dev/null +++ b/scripts/it/components/pubsub.yaml @@ -0,0 +1,13 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: pubsub +spec: + type: pubsub.redis + version: v1 + metadata: + # See statestore.yaml for the ${DAPR4S_IT_REDIS_HOST} substitution. + - name: redisHost + value: ${DAPR4S_IT_REDIS_HOST} + - name: redisPassword + value: "" diff --git a/scripts/it/components/secretstore.yaml b/scripts/it/components/secretstore.yaml new file mode 100644 index 0000000..c19f235 --- /dev/null +++ b/scripts/it/components/secretstore.yaml @@ -0,0 +1,12 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: secretstore +spec: + type: secretstores.local.file + version: v1 + metadata: + # Path as seen INSIDE the daprd container: the shared secrets.json is mounted at + # /dapr4s-it/secrets.json by both harnesses. + - name: secretsFile + value: /dapr4s-it/secrets.json diff --git a/scripts/it/components/statestore.yaml b/scripts/it/components/statestore.yaml new file mode 100644 index 0000000..5f29e8d --- /dev/null +++ b/scripts/it/components/statestore.yaml @@ -0,0 +1,18 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: statestore +spec: + type: state.redis + version: v1 + metadata: + # ${DAPR4S_IT_REDIS_HOST} is substituted by scripts/it/render-components.sh: the JVM + # testcontainers topology renders "redis:6379" (shared Docker network, alias "redis"), + # the JS host-network topology renders "localhost:6391" (see scripts/js-integration-env.sh). + - name: redisHost + value: ${DAPR4S_IT_REDIS_HOST} + - name: redisPassword + value: "" + # The Counter actor and the workflow runtime both store their state here. + - name: actorStateStore + value: "true" diff --git a/scripts/it/render-components.sh b/scripts/it/render-components.sh new file mode 100755 index 0000000..2164078 --- /dev/null +++ b/scripts/it/render-components.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Render the canonical Dapr component set (scripts/it/components/*.yaml) for one topology by +# substituting the single environment-specific value, ${DAPR4S_IT_REDIS_HOST}, into a fresh +# output directory. This is the ONE source of truth both integration harnesses consume: +# +# - the JVM testcontainers harness renders with redis:6379 (shared Docker network) and feeds +# each rendered file to io.dapr.testcontainers.DaprContainer.withComponent(Path); +# - the JS (Wasm+JSPI) harness renders with localhost:6391 (host network) and mounts the +# output dir into daprd via --resources-path. +# +# Usage: scripts/it/render-components.sh +# value for redisHost, e.g. "redis:6379" or "localhost:6391" +# directory to (re)create with the rendered *.yaml +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi +redis_host="$1" +out_dir="$2" +src_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/components" && pwd)" + +rm -rf "$out_dir" +mkdir -p "$out_dir" + +# Substitute ONLY ${DAPR4S_IT_REDIS_HOST} (export + envsubst with an explicit var list, so any +# other ${...} in the YAML is left untouched). +export DAPR4S_IT_REDIS_HOST="$redis_host" +for f in "$src_dir"/*.yaml; do + envsubst '${DAPR4S_IT_REDIS_HOST}' < "$f" > "$out_dir/$(basename "$f")" +done diff --git a/scripts/it/secrets.json b/scripts/it/secrets.json new file mode 100644 index 0000000..7b490a8 --- /dev/null +++ b/scripts/it/secrets.json @@ -0,0 +1,4 @@ +{ + "it-secret-a": "secret-value-alpha", + "it-secret-b": "secret-value-beta" +} diff --git a/scripts/js-integration-env.sh b/scripts/js-integration-env.sh new file mode 100755 index 0000000..8c027f9 --- /dev/null +++ b/scripts/js-integration-env.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# Environment for the Scala.js (Wasm + JSPI) integration tests: a real Dapr sidecar with +# Redis-backed components, the placement + scheduler services, and the dapr4s JS test server +# (test/js/integration/JsTestServer.scala) packaged to Wasm and run under Node. +# +# scripts/js-integration-env.sh up # start everything (idempotent: tears down first) +# scripts/js-integration-env.sh down # stop and remove everything +# +# Normally invoked via scripts/test-js-integration.sh, which runs the munit suites in between. +# +# == Port map (single source of truth on the infra side) == +# All ports are NON-default to avoid collisions with a locally `dapr init`-ed stack or other +# test harnesses. The Scala twin of this table lives in test/js/integration/JsItEnv.scala — +# keep the two in sync. +# +# redis 6391 (host port; container port 6379) +# placement 50091 (healthz 8691, metrics 9691) +# scheduler 51091 (healthz 8692, metrics 9692, etcd client 2391) +# daprd HTTP 3591 +# daprd gRPC 50191 (internal gRPC 50291, metrics 9593) +# app server 8391 (the Node test server; daprd's --app-port) +# +# == Container topology == +# daprd, placement and scheduler run with --network host: daprd must reach the app server on +# the host (localhost:8391), the test suites must reach daprd (localhost:3591/50191), and daprd +# must reach placement/scheduler — host networking makes all of that one address space, exactly +# like the R2 manual smoke setup. Redis uses an ordinary port mapping (6391->6379); daprd +# reaches it as localhost:6391. +# +# daprd 1.17 workflows REQUIRE the scheduler service (the workflow engine schedules its +# reminders there); actors require placement. Components come from the CANONICAL shared set +# (scripts/it/components/*.yaml — the same set the JVM testcontainers suites consume): state.redis +# (actorStateStore=true), pubsub.redis, lock.redis, configuration.redis, secretstores.local.file, +# crypto.dapr.localstorage. They are rendered for this host-network topology (redisHost +# localhost:6391) by scripts/it/render-components.sh into a per-run resource dir that also holds +# the shared secrets.json and a fresh RSA key under keys/; the whole dir is mounted into daprd at +# /dapr4s-it (see up() below). The resource dir lives under .scala-build (git-ignored). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCALA_CLI="${SCALA_CLI:-scala-cli}" + +DAPR_IMAGE_DAPRD="daprio/daprd:1.17.0" +DAPR_IMAGE_TOOLS="daprio/dapr:1.17.0" # placement + scheduler binaries +REDIS_IMAGE="redis:7-alpine" + +REDIS_PORT=6391 +PLACEMENT_PORT=50091 +SCHEDULER_PORT=51091 +DAPR_HTTP_PORT=3591 +DAPR_GRPC_PORT=50191 +DAPR_INTERNAL_GRPC_PORT=50291 +APP_PORT=8391 +APP_ID="js-it-server" + +C_REDIS="dapr4s-jsit-redis" +C_PLACEMENT="dapr4s-jsit-placement" +C_SCHEDULER="dapr4s-jsit-scheduler" +C_DAPRD="dapr4s-jsit-daprd" + +# Server process artifacts live under .scala-build (already git-ignored by scala-cli itself). +WORK_DIR="$ROOT/.scala-build/js-it" +DIST_DIR="$WORK_DIR/dist" +PID_FILE="$WORK_DIR/server.pid" +LOG_FILE="$WORK_DIR/server.log" +# Assembled fresh per `up`: the rendered shared components/, the shared secrets.json and a fresh +# crypto key under keys/ — mounted into daprd at /dapr4s-it. Under .scala-build (git-ignored). +DAPR_DIR="$WORK_DIR/dapr" + +log() { echo "[js-integration-env] $*" >&2; } + +# Dump the evidence before dying: a CI runner is discarded on failure, and the interesting bits +# (the Node server crash, a daprd component-init error) live only in $LOG_FILE / `docker logs`. +dump_diagnostics() { + if [ -f "$LOG_FILE" ]; then + log "---- tail of $LOG_FILE ----" + tail -n 100 "$LOG_FILE" >&2 || true + fi + for c in "$C_DAPRD" "$C_SCHEDULER" "$C_PLACEMENT" "$C_REDIS"; do + if docker inspect "$c" >/dev/null 2>&1; then + log "---- docker logs --tail 100 $c ----" + docker logs --tail 100 "$c" >&2 || true + fi + done +} + +die() { log "ERROR: $*"; dump_diagnostics; exit 1; } + +wait_for() { # wait_for + local desc="$1" timeout="$2"; shift 2 + local deadline=$((SECONDS + timeout)) + until "$@" >/dev/null 2>&1; do + if [ "$SECONDS" -ge "$deadline" ]; then + die "timed out after ${timeout}s waiting for $desc" + fi + sleep 0.5 + done + log "$desc is up" +} + +down() { + log "tearing down" + if [ -f "$PID_FILE" ]; then + local pid + pid="$(cat "$PID_FILE")" + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + for _ in $(seq 1 20); do kill -0 "$pid" 2>/dev/null || break; sleep 0.25; done + kill -9 "$pid" 2>/dev/null || true + fi + rm -f "$PID_FILE" + fi + # Belt and braces: also kill any orphaned server by its dist path. A server can outlive its + # PID file (e.g. a previous harness run crashed between writing the file and tearing down); + # a stale server keeps APP_PORT bound, the next run's server dies with EADDRINUSE, and the + # suites then talk to the stale server — whose workflow worker is dead after its daprd went + # away (upstream task-hub-grpc-worker isFirstAttempt bug). Symptom: workflow tests time out. + pkill -f "$DIST_DIR/main.js" 2>/dev/null || true + docker rm -f "$C_DAPRD" "$C_SCHEDULER" "$C_PLACEMENT" "$C_REDIS" >/dev/null 2>&1 || true + rm -rf "$DAPR_DIR" # per-run rendered components + secrets + crypto key (regenerated by up) +} + +up() { + down # idempotent restart + + # -- 1. Package the test server (Wasm + ES modules; the server main lives in TEST scope, + # hence --test: it reuses the shared test fixtures and test-only codecs). + # The dist dir sits inside the repo so Node's ESM resolver finds the repo-root + # node_modules (@dapr/dapr, express) by walking up from the module's own path. + # + # The ScalablyTyped facades are compileOnly.dep (js-deps.scala), so they are absent from + # the RUNTIME classpath that `package` links against — unlike `compile`/`test`, which use + # the compile/test classpath. Without them on the link classpath the link fails with + # "Referring to non-existent class dapr4styped...", so add the facade jars back via + # scripts/st-link-jars.sh (`--jar` flags; the linker de-duplicates, no POM impact). + log "packaging JsTestServer (Wasm) -> $DIST_DIR" + mkdir -p "$WORK_DIR" + local st_link + mapfile -t st_link < <("$ROOT/scripts/st-link-jars.sh") + "$SCALA_CLI" --power package --test --js --js-emit-wasm --js-module-kind es "$ROOT" \ + "${st_link[@]}" \ + --main-class dapr4s.test.integration.jsTestServerMain -o "$DIST_DIR" -f + + # -- 1b. Assemble the shared Dapr resource dir mounted into daprd at /dapr4s-it: + # components/ — the canonical scripts/it/components set rendered for this host-network + # topology (redisHost localhost:6391) via scripts/it/render-components.sh; + # secrets.json — the shared seed (scripts/it/secrets.json); + # keys/rsa-key — fresh per run for crypto.dapr.localstorage. PKCS#8 PEM ("BEGIN PRIVATE + # KEY"), matching the key CryptoCapabilityServerTest generates via + # java.security.KeyPairGenerator on the JVM. World-readable: daprd runs as + # a non-root user in the container and otherwise fails with "permission + # denied". The whole dir lives under .scala-build (git-ignored). + log "assembling shared Dapr resource dir -> $DAPR_DIR" + command -v openssl >/dev/null || die "openssl is required to generate the crypto test key" + rm -rf "$DAPR_DIR" + mkdir -p "$DAPR_DIR/keys" + "$ROOT/scripts/it/render-components.sh" "localhost:$REDIS_PORT" "$DAPR_DIR/components" + cp "$ROOT/scripts/it/secrets.json" "$DAPR_DIR/secrets.json" + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$DAPR_DIR/keys/rsa-key" 2>/dev/null \ + || die "openssl failed to generate the RSA key" + chmod -R a+rX "$DAPR_DIR" + + # -- 2. Infrastructure containers. + log "starting redis ($C_REDIS, host port $REDIS_PORT)" + docker run -d --name "$C_REDIS" -p "$REDIS_PORT:6379" "$REDIS_IMAGE" >/dev/null + wait_for "redis" 60 docker exec "$C_REDIS" redis-cli ping + + log "starting placement ($C_PLACEMENT, port $PLACEMENT_PORT)" + docker run -d --name "$C_PLACEMENT" --network host --entrypoint ./placement "$DAPR_IMAGE_TOOLS" \ + --port "$PLACEMENT_PORT" --healthz-port 8691 --metrics-port 9691 >/dev/null + + log "starting scheduler ($C_SCHEDULER, port $SCHEDULER_PORT)" + # The scheduler embeds etcd; give it a writable tmpfs and a non-default client port. + docker run -d --name "$C_SCHEDULER" --network host --entrypoint ./scheduler \ + --tmpfs /scheduler-data:rw,size=128m "$DAPR_IMAGE_TOOLS" \ + --port "$SCHEDULER_PORT" --healthz-port 8692 --metrics-port 9692 \ + --etcd-client-port 2391 --etcd-data-dir /scheduler-data >/dev/null + + log "starting daprd ($C_DAPRD, http $DAPR_HTTP_PORT / grpc $DAPR_GRPC_PORT)" + docker run -d --name "$C_DAPRD" --network host \ + -v "$DAPR_DIR:/dapr4s-it:ro" \ + "$DAPR_IMAGE_DAPRD" \ + ./daprd \ + --app-id "$APP_ID" \ + --app-port "$APP_PORT" \ + --app-protocol http \ + --dapr-http-port "$DAPR_HTTP_PORT" \ + --dapr-grpc-port "$DAPR_GRPC_PORT" \ + --dapr-internal-grpc-port "$DAPR_INTERNAL_GRPC_PORT" \ + --metrics-port 9593 \ + --placement-host-address "localhost:$PLACEMENT_PORT" \ + --scheduler-host-address "localhost:$SCHEDULER_PORT" \ + --resources-path /dapr4s-it/components \ + --log-level info >/dev/null + + # -- 3. The Node test server. Started after daprd so its WorkflowRuntime connects to a gRPC + # endpoint that is already listening; daprd in turn polls the app port, so the order is + # safe in both directions. + log "starting JsTestServer on port $APP_PORT (log: $LOG_FILE)" + (cd "$ROOT" && nohup node "$DIST_DIR/main.js" > "$LOG_FILE" 2>&1 & echo $! > "$PID_FILE") + wait_for "app server port $APP_PORT" 60 curl -fsS -o /dev/null "http://localhost:$APP_PORT/dapr/config" + + # daprd reports healthy only after the app channel is up and components are loaded. + wait_for "daprd healthz" 120 curl -fsS -o /dev/null "http://localhost:$DAPR_HTTP_PORT/v1.0/healthz" + + # -- 4. Seed configuration items for ConfigurationJsIntegrationTest (Dapr's redis + # configuration store reads plain keys; "value||version" splits into value + version). + log "seeding configuration keys" + docker exec "$C_REDIS" redis-cli MSET \ + dapr4s-it-cfg-a "alpha||v1" \ + dapr4s-it-cfg-b "beta||v2" >/dev/null + + log "environment is up" +} + +case "${1:-}" in + up) up ;; + down) down ;; + *) die "usage: $0 up|down" ;; +esac diff --git a/scripts/js-it/node-resolve-delegate.mjs b/scripts/js-it/node-resolve-delegate.mjs new file mode 100644 index 0000000..4073957 --- /dev/null +++ b/scripts/js-it/node-resolve-delegate.mjs @@ -0,0 +1,29 @@ +// Module-resolution delegate registered by node-resolve-hook.mjs — see that file for WHY. +// +// Resolution strategy: let Node resolve normally first; only when a BARE specifier (an npm +// package name — not relative, not absolute, not a URL) fails with ERR_MODULE_NOT_FOUND, retry +// the resolution as if the import came from /package.json, which makes Node's +// walk-up find the repo's node_modules. DAPR4S_REPO_ROOT is exported by +// scripts/test-js-integration.sh; the cwd fallback covers manual invocations from the repo root. +import { pathToFileURL } from "node:url"; + +const repoRoot = process.env.DAPR4S_REPO_ROOT ?? process.cwd(); +const fallbackParent = pathToFileURL(`${repoRoot}/package.json`).href; + +const isBare = (specifier) => + !specifier.startsWith(".") && + !specifier.startsWith("/") && + !specifier.startsWith("file:") && + !specifier.startsWith("node:") && + !specifier.startsWith("data:"); + +export async function resolve(specifier, context, nextResolve) { + try { + return await nextResolve(specifier, context); + } catch (err) { + if (err?.code === "ERR_MODULE_NOT_FOUND" && isBare(specifier)) { + return nextResolve(specifier, { ...context, parentURL: fallbackParent }); + } + throw err; + } +} diff --git a/scripts/js-it/node-resolve-hook.mjs b/scripts/js-it/node-resolve-hook.mjs new file mode 100644 index 0000000..1e68948 --- /dev/null +++ b/scripts/js-it/node-resolve-hook.mjs @@ -0,0 +1,14 @@ +// Entry point for Node's module-customization hooks, activated via +// NODE_OPTIONS="--import " +// by scripts/test-js-integration.sh. +// +// WHY: `scala-cli test` on Scala.js links the test module into a temp dir (e.g. +// /tmp/mainXXXX.mjs/main.js) and runs Node there. ESM resolution of bare specifiers walks up +// from the importing MODULE's own path — it ignores both the working directory and NODE_PATH — +// so `import '@dapr/dapr'` cannot find the repo's node_modules from /tmp. This registers +// node-resolve-delegate.mjs (a separate file: the hooks module runs on a dedicated loader +// thread, so self-registration would recurse), which retries failed bare-specifier resolutions +// with the repo root as the parent. +import { register } from "node:module"; + +register(new URL("./node-resolve-delegate.mjs", import.meta.url)); diff --git a/scripts/k8s-test.sh b/scripts/k8s-test.sh index 09f2f3c..450d4cb 100755 --- a/scripts/k8s-test.sh +++ b/scripts/k8s-test.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# k8s-test.sh — Full lifecycle k3d integration test for scala-safe-dapr +# k8s-test.sh — Full lifecycle k3d integration test for dapr4s # # Prerequisites: # k3d (>= v5.8), kubectl, dapr CLI, Docker, scala-cli, jq @@ -103,14 +103,14 @@ if [[ "$SKIP_BUILD" == false ]]; then scala-cli --power package . \ --assembly \ - --main-class "dapr.safe.test.integration.apps.orderServiceMain" \ + --main-class "dapr4s.test.integration.apps.orderServiceMain" \ -o order-service.jar \ --force info "Built order-service.jar ($(du -sh order-service.jar | cut -f1))" scala-cli --power package . \ --assembly \ - --main-class "dapr.safe.test.integration.apps.inventoryServiceMain" \ + --main-class "dapr4s.test.integration.apps.inventoryServiceMain" \ -o inventory-service.jar \ --force info "Built inventory-service.jar ($(du -sh inventory-service.jar | cut -f1))" diff --git a/scripts/st-link-jars.sh b/scripts/st-link-jars.sh new file mode 100755 index 0000000..cd836c6 --- /dev/null +++ b/scripts/st-link-jars.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Print `--jar ` tokens for the ScalablyTyped facade jars, for adding them to the LINK +# classpath of a Scala.js `scala-cli package`/`run` invocation. +# +# WHY: js-deps.scala declares the facades as `compileOnly.dep` so the ivy-local-only +# org.scalablytyped coordinates never reach the published POM (see js-deps.scala). `compileOnly` +# keeps them on the compile AND test classpaths — so `scala-cli compile --js` and +# `scala-cli test --js` link fine — but NOT on the runtime classpath that `scala-cli package` +# (and `run`) link against. Building the JsTestServer main with `package --test` therefore fails +# at link time with "Referring to non-existent class dapr4styped..." unless the facade .sjsir +# are put back on the classpath. This script resolves exactly the transitively-required +# org.scalablytyped jars (the same set scripts/embed-st-facades.sh embeds into the published jar) +# and emits them as `--jar` flags: +# +# scala-cli --power package --test --js ... $(scripts/st-link-jars.sh) ... +# +# Adding them alongside the compileOnly deps is safe — the linker de-duplicates by class name +# (verified: no duplicate-class errors), and `--jar` affects only this invocation's classpath, +# never the published POM. +# +# The root coordinates are read from js-deps.scala (single source of truth), NOT globbed from +# ~/.ivy2/local/org.scalablytyped, which accumulates stale artifacts from older digests. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +JS_DEPS="${REPO_ROOT}/js-deps.scala" + +command -v cs >/dev/null || { echo "ERROR: coursier ('cs') is required on PATH." >&2; exit 1; } + +# `org.scalablytyped::name::ver` (scala-cli cross syntax) -> `org.scalablytyped:name_sjs1_3:ver`. +mapfile -t roots < <( + grep -oE 'using compileOnly\.dep "org\.scalablytyped::[^"]+"' "${JS_DEPS}" \ + | sed -E 's/.*"org\.scalablytyped::([^:]+)::([^"]+)"/org.scalablytyped:\1_sjs1_3:\2/' +) +if [[ "${#roots[@]}" -ne 3 ]]; then + echo "ERROR: expected exactly 3 org.scalablytyped compileOnly deps in js-deps.scala, found ${#roots[@]}." >&2 + exit 1 +fi + +mapfile -t st_jars < <( + cs fetch --repository ivy2Local --repository central "${roots[@]}" \ + | grep -E '/org\.scalablytyped/|/org/scalablytyped/' +) +if [[ "${#st_jars[@]}" -lt 3 ]]; then + echo "ERROR: coursier resolved only ${#st_jars[@]} org.scalablytyped jars — run scripts/generate-st-facades.sh first." >&2 + exit 1 +fi + +for jar in "${st_jars[@]}"; do + printf -- '--jar\n%s\n' "${jar}" +done diff --git a/scripts/test-js-integration.sh b/scripts/test-js-integration.sh new file mode 100755 index 0000000..77cf286 --- /dev/null +++ b/scripts/test-js-integration.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# One-command entry point for the Scala.js integration tests (CI and developers): +# +# 1. brings up the Dapr environment + the packaged JS test server +# (scripts/js-integration-env.sh up), +# 2. runs the dapr4s.test.integration.* munit suites on the experimental WebAssembly +# backend (Wasm + JSPI; scripts/wasm-test.sh handles the known scala-cli cleanup bug), +# 3. always tears the environment down again, preserving the test exit code. +# +# Requirements: +# - Node.js >= 25 first on PATH (JSPI is on by default there; checked below). +# Locally e.g.: PATH=/tmp/node-v25.5.0-linux-x64/bin:$PATH scripts/test-js-integration.sh +# CI installs it via setup-node. +# - scala-cli >= 1.14 (override with SCALA_CLI=/path/to/scala-cli). +# - Docker. +# - The ScalablyTyped facade jars in ~/.ivy2/local (scripts/generate-st-facades.sh) and +# `npm ci` done at the repo root (the test server loads @dapr/dapr at runtime). +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# -- Node >= 25 check (JSPI by default; Node 23/24 would need --experimental-wasm-jspi, which +# scala-cli's runner does not pass — see wiki/scala-js/scala-js-async-jspi-wasm.md). +if ! command -v node >/dev/null 2>&1; then + echo "ERROR: node not found on PATH; the Wasm+JSPI tests need Node >= 25." >&2 + exit 1 +fi +node_version="$(node --version)" # e.g. v25.5.0 +node_major="${node_version#v}"; node_major="${node_major%%.*}" +if [ "$node_major" -lt 25 ]; then + echo "ERROR: Node >= 25 required for JSPI (found $node_version)." >&2 + echo " Locally: PATH=/tmp/node-v25.5.0-linux-x64/bin:\$PATH $0" >&2 + exit 1 +fi + +"$ROOT/scripts/js-integration-env.sh" up || exit 1 + +# scala-cli runs the linked test module from a temp dir, where Node's ESM resolver cannot see +# the repo's node_modules (bare specifiers resolve relative to the MODULE's path; NODE_PATH is +# ignored for ES modules). The resolution hook retries bare specifiers against the repo root — +# see scripts/js-it/node-resolve-hook.mjs. +export DAPR4S_REPO_ROOT="$ROOT" +export NODE_OPTIONS="--import $ROOT/scripts/js-it/node-resolve-hook.mjs" + +# The ScalablyTyped facades are compileOnly.dep (js-deps.scala), which leaves them off the +# classpath this Wasm `test` link uses — so add their jars back via --jar, same as the +# JsTestServer package step in js-integration-env.sh. (The plain-JS unit `test` leg links without +# them, but the Wasm `test`/`package` link does not — see scripts/st-link-jars.sh.) +mapfile -t st_link < <("$ROOT/scripts/st-link-jars.sh") + +"$ROOT/scripts/wasm-test.sh" \ + --power --js --js-emit-wasm --js-module-kind es "$ROOT" \ + "${st_link[@]}" \ + --test-only 'dapr4s.test.integration.*' +code=$? +unset NODE_OPTIONS DAPR4S_REPO_ROOT + +"$ROOT/scripts/js-integration-env.sh" down || true +exit "$code" diff --git a/scripts/wasm-test.sh b/scripts/wasm-test.sh new file mode 100755 index 0000000..77bec4a --- /dev/null +++ b/scripts/wasm-test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Wrapper for `scala-cli test` on the Scala.js WebAssembly backend. +# +# scala-cli 1.14.0 (latest as of 2026-06) always exits 1 after a Wasm test run, even when all +# tests pass: its cleanup does Files.deleteIfExists on the linked output `/tmp/mainXXXX.mjs`, +# which for Wasm is a non-empty DIRECTORY (__loader.js + main.js + main.wasm), so it throws +# java.nio.file.DirectoryNotEmptyException after the test runner already finished. +# (scala.cli.commands.run.Run$.withLinkedJs, Run.scala:728) +# +# This wrapper tolerates EXACTLY that failure mode and nothing else: it exits 0 only when +# scala-cli itself exited 0 (bug fixed upstream), or when the output shows the known exception +# with every suite reporting "0 failed" and no incomplete runs. +# +# Usage: scripts/wasm-test.sh [scala-cli test args...] +# SCALA_CLI=/path/to/scala-cli scripts/wasm-test.sh --power --js --js-emit-wasm --js-module-kind es . +set -uo pipefail + +SCALA_CLI="${SCALA_CLI:-scala-cli}" +log=$(mktemp) +trap 'rm -f "$log" "$log.raw"' EXIT + +"$SCALA_CLI" test "$@" 2>&1 | tee "$log.raw" +code=${PIPESTATUS[0]} +# Strip ANSI colors (scala-cli colorizes even when piped). +sed 's/\x1b\[[0-9;]*m//g' "$log.raw" > "$log" + +if [ "$code" -eq 0 ]; then + exit 0 # future-proof: bug fixed upstream +fi + +fail=1 +if grep -q "DirectoryNotEmptyException" "$log" \ + && ! grep -q "Incomplete runs" "$log" \ + && grep -q "finished: 0 failed" "$log" \ + && ! grep -E "finished: [0-9]+ failed" "$log" | grep -v "finished: 0 failed" | grep -q .; then + fail=0 +fi + +if [ "$fail" -eq 0 ]; then + echo "NOTE: all tests passed; tolerated known scala-cli wasm cleanup bug (DirectoryNotEmptyException on temp .mjs dir)." >&2 + exit 0 +fi +exit "$code" diff --git a/src/js/Dapr.scala b/src/js/Dapr.scala new file mode 100644 index 0000000..11124a5 --- /dev/null +++ b/src/js/Dapr.scala @@ -0,0 +1,189 @@ +//> using target.platform "scala-js" +package dapr4s + +import scala.scalajs.js +import scala.util.control.NonFatal +import dapr4styped.daprDapr.mod.{DaprClient, DaprWorkflowClient} + +/** Entry point that manages the [[DaprCapability]] lifecycle — the Scala.js twin of the JVM `Dapr`, backed by the Dapr + * JS SDK (`@dapr/dapr`). + * + * Construct with a [[DaprConfig]] (defaults to sensible local-sidecar settings) and call `run` (or the JS-only + * `runAsync`): + * + * {{{ + * // one-shot request/response, with the single js.async entry at the program edge: + * def main(args: Array[String]): Unit = + * js.async { + * Dapr().run: + * summon[DaprCapability].state(StateStoreName("statestore")).get(StateStoreKey("k")) + * }: Unit + * }}} + * + * ==WebAssembly + JSPI requirement== + * + * The capability implementations stay in direct (synchronous-looking) style by suspending on every SDK promise via an + * orphan `js.await` (see [[dapr4s.internal.JsAwait]]). That mechanism — JavaScript Promise Integration — is the JS + * analogue of the virtual threads the JVM scaladoc documents: instead of parking a virtual thread in + * `CompletableFuture.get()`, JSPI suspends the WebAssembly stack and lets the event loop keep running. It comes with + * hard platform requirements: + * + * - link with the '''experimental WebAssembly backend''' (`//> using jsEmitWasm true`, `//> using jsModuleKind es`); + * on the plain JS backend, code reaching `run` '''fails at link time''' — by design, a clean failure mode (the + * pure parts of dapr4s still link on plain JS); + * - run on '''Node 25+''' (JSPI on by default) or Node 23/24 with `--experimental-wasm-jspi`; + * - the caller must be inside a `js.async { ... }` block — one entry at the program edge as in the example above (or + * use [[runAsync]], which wraps it for you). Suspension cannot cross a JavaScript stack frame, so a Scala lambda + * invoked by a JS API must open its own `js.async` before touching capabilities. + * + * ==Configuration mapping== + * + * `config.sidecar.httpEndpoint` drives the default HTTP-protocol SDK client; `grpcEndpoint` drives the lazily created + * gRPC client (configuration + crypto are gRPC-only in the JS SDK) and the workflow client; `apiToken` becomes the + * SDK's `daprApiToken`. Config knobs without a JS equivalent — the OkHttp pool settings + * (`httpClientReadTimeout`/`MaxRequests`/`MaxIdleConnections`), the gRPC-Java keepalive settings, `maxRetries`, + * `timeout`, and the TLS material paths (`grpcTlsCertPath`/`KeyPath`/`CaPath`, `grpcTlsInsecure`) — are ignored on + * this platform (TLS on/off still follows the endpoint URI scheme). + * + * Annotated `@scala.caps.assumeSafe` so that safe-mode user code can call `Dapr(config).run` without seeing any unsafe + * operations. The internal use of `DaprCapabilityImpl` (a JS-SDK-backed class) and the ScalablyTyped-generated SDK + * clients it wraps (`dapr4styped.daprDapr` — see js-deps.scala) are managed entirely here. + */ +@scala.caps.assumeSafe +class Dapr(config: DaprConfig = DaprConfig()): + + /** Acquire a Dapr JS SDK client, run `body` with a `DaprCapability` in context, then release the client whether + * `body` completes normally or throws. + * + * Three clients are potentially created: an HTTP-protocol `DaprClient` (always), a gRPC-protocol `DaprClient` and a + * `DaprWorkflowClient` (lazily, only when `configuration`/`crypto` / `workflow` are first used). All three are + * stopped in the `finally` block in order; if any stop throws a non-fatal exception, it is suppressed onto the + * body's throwable (or rethrown standalone if the body succeeded) — the same tryClose+suppression structure as the + * JVM `Dapr.run` (minus its `InterruptedException` branch: there are no threads to interrupt on JS). + * + * No eager `client.start()` is needed: every SDK sub-client call auto-starts its client (awaiting sidecar health) on + * first use — mirroring the JVM, where `DaprClientBuilder.build()` is also lazy. + * + * Must be called within a `js.async { ... }` context on the Wasm backend — see the class scaladoc; use [[runAsync]] + * when a `js.Promise` is the more natural shape at the call site. + * + * @param body + * a pure context function that receives a `DaprCapability` + * @return + * the value returned by `body` + */ + def run[T](body: DaprCapability ?=> T): T = + val sc = config.sidecar + val client = new DaprClient(internal.DaprCapabilityImpl.httpClientOptions(sc)) + val grpcClientRef = new internal.LazyClientRef[DaprClient] + val workflowClientRef = new internal.LazyClientRef[DaprWorkflowClient] + val impl = new internal.DaprCapabilityImpl(client, sc, grpcClientRef, workflowClientRef) + var primary: Throwable | Null = null + try body(using impl) + catch + case NonFatal(t) => + primary = t + throw t + finally + var closeEx: Throwable | Null = null + // Awaiting the SDK's async stop() keeps run's contract synchronous: it only returns once all + // connections are released (the JVM twin's close() calls are synchronous too). The JsAwait + // suspension rules apply, which is fine — the finally block runs in the same Wasm/JSPI + // context as run itself. + def tryClose(stop: () => js.Promise[Unit]): Unit = + try internal.JsAwait.await(stop()) + catch + case NonFatal(t) => + if closeEx == null then closeEx = t + else closeEx.nn.addSuppressed(t) + tryClose(() => client.stop()) + grpcClientRef.created.foreach(c => tryClose(() => c.stop())) + workflowClientRef.created.foreach(c => tryClose(() => c.stop())) + val ce = closeEx + if ce != null then + val p = primary + if p != null then p.addSuppressed(ce) + else throw ce + + /** JS-only convenience: [[run]] wrapped in its own `js.async { ... }` entry, returning the result as a `js.Promise`. + * Use this when the caller is plain JavaScript-side code (or a `main` that has nothing else to await) and does not + * want to open the `js.async` block itself. + */ + def runAsync[T](body: DaprCapability ?=> T): js.Promise[T] = + js.async { + run(body) + } + + /** Start an HTTP server on `config.appServer.port`, build the inbound handler set from the [[DaprApp]] returned by + * `body`, then suspend forever (until the process receives SIGINT/SIGTERM) — the Scala.js twin of the JVM `serve`. + * + * The Dapr sidecar discovers pub/sub subscriptions via `GET /dapr/subscribe` and hosted actor types via + * `GET /dapr/config`, and delivers messages / binding events / invocations / job triggers / actor calls over the + * same app-channel routes the JVM server exposes (the express-based [[dapr4s.internal.DaprAppServer]] twin). Each + * inbound request gets its own `js.async` entry, so handlers can use capabilities (suspend) freely — one "virtual + * thread" per request. + * + * ==Usage== + * {{{ + * def main(args: Array[String]): Unit = + * js.async { + * Dapr(config).serve: + * val scope = summon[DaprCapability] + * given StateCapability = scope.state(StateStoreName("statestore")) + * given PublishCapability = scope.publish(PubSubName("pubsub")) + * DaprApp( + * subscriptions = List( + * Subscription[OrderEvent](PubSubName("pubsub"), Topic("orders")) { event => + * // handle incoming order event + * SubscriptionResult.Success + * } + * ), + * invokeRoutes = List( + * InvokeRoute[OrderRequest, OrderResponse](InvokeMethodName("place-order")) { req => + * // handle direct invocation + * OrderResponse(req.id, "processed") + * } + * ) + * ) + * }: Unit + * }}} + * + * The single `js.async` at the program edge satisfies the Wasm/JSPI requirement documented on this class; `serve` + * itself never resumes that block (it suspends on a never-resolving promise — the JS analogue of the JVM's + * `Thread.currentThread().join()`), so the `Nothing` result type is honoured and the express server keeps the event + * loop alive. Use [[serveAsync]] when opening the `js.async` block yourself is inconvenient. + * + * ==Sidecar startup order== + * Start the app (this method) before (or at the same time as) the Dapr sidecar. The sidecar calls + * `GET /dapr/subscribe` after connecting to the app port (`--app-port`). + * + * Workflow/activity hosting works on this platform too: a [[DaprApp]] with non-empty `workflows`/`activities` starts + * a `WorkflowRuntime` (gRPC, against `config.sidecar.grpcEndpoint`) before the HTTP server binds — see + * `dapr4s.internal.WorkflowHost` and the AsyncGenerator coroutine bridge in `dapr4s.internal.WorkflowCoroutine`. + * + * @param body + * a pure context function that receives a `DaprCapability` and returns a [[DaprApp]] describing all inbound + * handlers + */ + def serve(body: DaprCapability ?=> DaprApp): Nothing = + run: + val cap = summon[DaprCapability] + // Fail fast on structural misconfiguration (duplicate/colliding handlers) before binding the port. + val app = body.validateOrThrow() + new internal.DaprAppServer(app).startAndBlock( + port = config.appServer.port.value, + daprCapability = cap, + sidecar = config.sidecar, + shutdownGrace = config.appServer.shutdownGrace, + httpBacklog = config.appServer.httpBacklog, + actorConfig = config.actors, + ) + + /** JS-only convenience twin of [[serve]], mirroring [[runAsync]]: [[serve]] wrapped in its own `js.async { ... }` + * entry. The returned `js.Promise[Nothing]` never fulfills (the server suspends forever); it rejects only if startup + * fails (validation error, port already bound) or the server errors fatally. + */ + def serveAsync(body: DaprCapability ?=> DaprApp): js.Promise[Nothing] = + js.async { + serve(body) + } diff --git a/src/js/DaprCapabilityPlatform.scala b/src/js/DaprCapabilityPlatform.scala new file mode 100644 index 0000000..83911f9 --- /dev/null +++ b/src/js/DaprCapabilityPlatform.scala @@ -0,0 +1,24 @@ +//> using target.platform "scala-js" +package dapr4s + +/** Scala.js half of the [[DaprCapability]] surface — deliberately empty. + * + * The Dapr JS SDK (`@dapr/dapr` 3.x) has no jobs or conversation API, so the `jobs` and `conversation` factory methods + * exist only on the JVM platform trait (`src/jvm/DaprCapabilityPlatform.scala`). Using them from Scala.js code is a + * compile-time error by design — there is no method to call, instead of a runtime `UnsupportedOperationException` (see + * the platform-surface note on [[DaprCapability]]). + * + * WHY @assumeSafe: kept for symmetry with the JVM twin so [[DaprCapability]] composes the same trait shape on both + * platforms; an empty trait asserts nothing. + */ +@scala.caps.assumeSafe +trait DaprCapabilityPlatform + +/** Scala.js half of the [[DaprCapability$ DaprCapability companion]] transformer API — deliberately empty for the same + * reason as [[DaprCapabilityPlatform]]: the Dapr JS SDK has no jobs or conversation API, so the `jobs`/`conversation` + * transformer methods exist only on the JVM twin and using them from Scala.js code is a compile-time error by design. + * + * WHY @assumeSafe: symmetry with the JVM twin; an empty trait asserts nothing. + */ +@scala.caps.assumeSafe +trait DaprCapabilityCompanionPlatform diff --git a/src/js/derivation/ForwardersPlatform.scala b/src/js/derivation/ForwardersPlatform.scala new file mode 100644 index 0000000..cdc6702 --- /dev/null +++ b/src/js/derivation/ForwardersPlatform.scala @@ -0,0 +1,14 @@ +//> using target.platform "scala-js" +package dapr4s.derivation + +/** Scala.js half of [[Forwarders]] — deliberately empty. + * + * The jobs forwarders (`jobSchedule`/`jobScheduleOnce`/`jobGet`) exist only on the JVM twin: they forward to the + * JVM-only `dapr4s.JobsCapability` (the Dapr JS SDK has no jobs API), and the `Jobs.derive` macro that generates calls + * to them is itself JVM-only. On Scala.js neither the capability nor the macro exists, so this trait has nothing to + * contribute — referencing a jobs forwarder from JS code is a compile-time error by design. + * + * WHY @assumeSafe: symmetry with the JVM twin; an empty trait asserts nothing. + */ +@scala.caps.assumeSafe +trait ForwardersPlatform diff --git a/src/js/internal/ActorCapabilityImpl.scala b/src/js/internal/ActorCapabilityImpl.scala new file mode 100644 index 0000000..61c055e --- /dev/null +++ b/src/js/internal/ActorCapabilityImpl.scala @@ -0,0 +1,122 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.scalajs.js +import dapr4styped.node.globalsMod.global as NodeGlobals +import dapr4styped.undiciTypes.fetchMod.RequestInit + +/** Client-side capability for invoking methods on a specific Dapr virtual actor instance — the Scala.js twin of the JVM + * `ActorCapabilityImpl`. + * + * ==Why raw sidecar HTTP instead of the JS SDK== + * + * The SDK's low-level actor client (`ActorClientHTTP` in `actors/client/ActorClient/`) is exactly what we need, but it + * is '''not exported from the package root''' (`index.ts` only exports `ActorId`, `ActorProxyBuilder`, + * `AbstractActor`), and `@dapr/dapr` has no `exports` map, so deep-requiring it is an unsupported API that can break + * on any release. The exported alternative, `ActorProxyBuilder`, derives the actor type string from + * `actorTypeClass.name` and returns a JS `Proxy` that turns '''every property access''' into an actor invocation — JS + * class-name reflection that is hostile to Scala.js (class names are mangled/minified, and the Proxy contract doesn't + * fit a typed facade). So this class speaks the sidecar's actor HTTP API directly over the Node-global `fetch` (typed + * by the ScalablyTyped `@types/node` conversion) + [[JsAwait]] — the same SDK-bypass precedent as the JVM + * `HttpActorContext` (which bypasses the Java SDK for actor state/reminders/timers for analogous reasons). The verb + * and path mirror `ActorClientHTTP.invoke`: `POST {sidecar}/v1.0/actors/{type}/{id}/method/{name}` (Dapr accepts PUT + * and POST; the JS SDK always POSTs). + * + * Serialization uses raw pass-through like the JVM twin: the request value is encoded to JSON by our [[JsonCodec]] and + * sent verbatim as the body with `application/json`; the response body text is decoded by the same codec — no SDK + * serializer ever touches the payload. + */ +@scala.caps.assumeSafe +private[internal] final class ActorCapabilityImpl( + val actorType: ActorType, + val actorId: ActorId, + private val sidecar: SidecarConfig, +) extends ActorCapability: + + import ActorCapabilityImpl.* + + private def methodUrl(method: ActorMethodName): String = + val base = httpBase(sidecar) + val tpe = urlSegment(actorType.value) + val id = urlSegment(actorId.value) + s"$base/v1.0/actors/$tpe/$id/method/${urlSegment(method.value)}" + + def invoke[Req: JsonCodec](method: ActorMethodName, data: Req)[Resp: JsonCodec]: Resp = + val body = summon[JsonCodec[Req]].encode(data) + val responseStr = post(methodUrl(method), Some(body), sidecar) + decodeResponse[Resp](actorType, method, responseStr) + + def invoke[Resp: JsonCodec](method: ActorMethodName): Resp = + val responseStr = post(methodUrl(method), None, sidecar) + decodeResponse[Resp](actorType, method, responseStr) + + def invokeVoid(method: ActorMethodName): Unit = + post(methodUrl(method), None, sidecar): Unit + +@scala.caps.assumeSafe +private[internal] object ActorCapabilityImpl: + + /** Percent-encode one path segment (or query key/value) of a raw sidecar URL, shared with the other raw-fetch call + * sites. Domain values (state keys, actor ids, method/topic names, ...) are interpolated into URLs here, so reserved + * characters (space, `/`, `?`, `#`, `%`, ...) must be encoded or they corrupt the request path; daprd decodes the + * escapes back before routing. + */ + private[internal] def urlSegment(value: String): String = + js.URIUtils.encodeURIComponent(value) + + /** Base URL of the sidecar HTTP API (scheme://host:port, no trailing slash), shared with the other raw-fetch call + * sites (see `StateCapabilityImpl.getWithETag`). + */ + private[internal] def httpBase(sidecar: SidecarConfig): String = + val uri = sidecar.httpEndpoint + val scheme = uri.getScheme match + case null => "http" + case s => s + val host = uri.getHost match + case null => "localhost" + case h => h + val port = uri.getPort match + case -1 if scheme == "https" => 443 + case -1 => 80 + case p => p + s"$scheme://$host:$port" + + /** Headers common to every raw sidecar call: the `dapr-api-token` header when configured (the same header the SDK and + * the JVM client send) plus our content type. + * + * Shaped as the `[[name, value], ...]` pairs array because that is the one member of the fetch `HeadersInit` union + * (`js.Array[js.Array[String]] | Record[...] | Headers` in the ScalablyTyped typing) that call sites can also extend + * with extra headers (`push`) without fighting `StringDictionary`'s invariance. + */ + private[internal] def baseHeaders(sidecar: SidecarConfig): js.Array[js.Array[String]] = + val headers = js.Array(js.Array("Content-Type", "application/json")) + sidecar.apiToken.foreach(t => headers.push(js.Array("dapr-api-token", t.value)): Unit) + headers + + /** POST `body` (if any) and return the response text; throw on HTTP >= 400 with the same message shape as the JVM + * `HttpActorContext.postJson` (`"Dapr API error $code at $url: $errBody"`). + */ + private def post(url: String, body: Option[String], sidecar: SidecarConfig): String = + val init = RequestInit().setMethod("POST").setHeaders(baseHeaders(sidecar)) + body.foreach(b => init.setBody(b): Unit) + val response = JsAwait.await(NodeGlobals.fetch(url, init)) + val text = JsAwait.await(response.text()) + if response.status >= 400 then throw new RuntimeException(s"Dapr API error ${response.status} at $url: $text") + text + + /** Decode an actor response, mirroring the JVM twin: a `null`/empty body reaches the codec as the empty string, and a + * decode failure is wrapped in [[JsonDecodeException]] with the actor/method context. + */ + private def decodeResponse[Resp: JsonCodec]( + actorType: ActorType, + method: ActorMethodName, + responseStr: String, + ): Resp = + summon[JsonCodec[Resp]].decode(responseStr) match + case Left(err) => + throw JsonDecodeException( + s"Actor '${actorType.value}/${method.value}' response decode failed: ${err.getMessage}", + err, + ) + case Right(v) => v diff --git a/src/js/internal/BindingsCapabilityImpl.scala b/src/js/internal/BindingsCapabilityImpl.scala new file mode 100644 index 0000000..abe60b2 --- /dev/null +++ b/src/js/internal/BindingsCapabilityImpl.scala @@ -0,0 +1,46 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.scalajs.js +import JsInterop.* + +@scala.caps.assumeSafe +private[internal] final class BindingsCapabilityImpl( + scope: DaprCapabilityImpl, + val bindingName: BindingName, +) extends BindingsCapability: + + def invoke[Req: JsonCodec]( + operation: BindingOperation, + data: Req, + metadata: Map[MetadataKey, MetadataValue] = Map.empty, + )[Resp: JsonCodec]: Option[Resp] = + val response = invokeRaw(operation, data, metadata) + // None when the binding returned no payload (undefined/empty), mirroring the JVM's null/empty + // byte-array check; the empty string is HTTPClient.execute's tryParseJson artifact for an + // empty response body. Consequently a binding response document that IS the JSON empty string + // (`""`) cannot be distinguished from an absent body post-SDK and also maps to None — a + // documented, accepted divergence from the JVM, which sees the raw bytes (see + // JsInterop.jsonStringOrNull). + if isAbsent(response) then None + else Some(JsonCodec.decodeOrThrow[Resp](js.JSON.stringify(response))) + + def invokeOneWay[Req: JsonCodec]( + operation: BindingOperation, + data: Req, + metadata: Map[MetadataKey, MetadataValue] = Map.empty, + ): Unit = + invokeRaw(operation, data, metadata): Unit + + // `binding.send` wraps everything into the {operation, data, metadata} body of POST /v1.0/bindings/{name} + // (implementation/Client/HTTPClient/binding.js) — `data` is embedded as a JS value, so the pre-encoded JSON is + // parsed first; the wrapper object then serializes as application/json, putting our exact JSON document into + // `data` like the JVM's byte[] pass-through does. Errors reject the promise and propagate (JVM: DaprException). + private def invokeRaw[Req: JsonCodec]( + operation: BindingOperation, + data: Req, + metadata: Map[MetadataKey, MetadataValue], + ): js.Any = + val json = summon[JsonCodec[Req]].encode(data) + JsAwait.await(scope.client.binding.send(bindingName.value, operation.value, parseJson(json), toDict(metadata))) diff --git a/src/js/internal/ConfigurationCapabilityImpl.scala b/src/js/internal/ConfigurationCapabilityImpl.scala new file mode 100644 index 0000000..cdd387d --- /dev/null +++ b/src/js/internal/ConfigurationCapabilityImpl.scala @@ -0,0 +1,111 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.scalajs.js +import scala.scalajs.js.JSConverters.* +import scala.util.control.NonFatal +import JsInterop.* +import dapr4styped.daprDapr.typesConfigurationConfigurationItemMod.ConfigurationItem as SdkConfigurationItem +import dapr4styped.daprDapr.typesConfigurationSubscribeConfigurationCallbackMod.SubscribeConfigurationCallback +import dapr4styped.daprDapr.typesConfigurationSubscribeConfigurationResponseMod.SubscribeConfigurationResponse + +@scala.caps.assumeSafe +private[internal] final class ConfigurationCapabilityImpl( + scope: DaprCapabilityImpl, + val storeName: ConfigurationStoreName, +) extends ConfigurationCapability: + + import ConfigurationCapabilityImpl.* + + // Both operations go through scope.grpcClient (the lazily-created gRPC-protocol DaprClient): + // configuration is gRPC-only in the JS SDK — the HTTP implementation throws HTTPNotSupportedError + // (implementation/Client/HTTPClient/configuration.js). + + def get(keys: Seq[ConfigurationKey], metadata: Map[MetadataKey, MetadataValue] = Map.empty): Map[ConfigurationKey, ConfigurationItem] = + val response = JsAwait.await( + scope.grpcClient.configuration.get(storeName.value, keys.map(_.value).toJSArray, toDict(metadata)), + ) + response.items.iterator.map { case (k, item) => ConfigurationKey(k) -> toConfigItem(k, item) }.toMap + + def subscribe(keys: Seq[ConfigurationKey], metadata: Map[MetadataKey, MetadataValue] = Map.empty)( + onChange: ConfigurationUpdate => Unit, + ): AutoCloseable^{this} = + val storeNameStr = storeName.value + // The callback is invoked by the SDK from a JavaScript frame (the `for await` loop over the + // gRPC stream in GRPCClientConfiguration._subscribe), and JSPI suspension cannot cross a JS + // frame — so the callback opens a FRESH `js.async { ... }` entry. Without it, any capability + // call inside the user's onChange (an orphan js.await deeper in the stack) would throw + // WebAssembly.SuspendError because no dynamically enclosing js.async would be reachable + // without an intervening JS frame. The js.Promise the async block returns is exactly what the + // SDK's `await cb(...)` contract expects. + val callback: js.Function1[SubscribeConfigurationResponse, js.Promise[Unit]]^{this, onChange} = + (response: SubscribeConfigurationResponse) => + js.async { + val items = response.items.iterator.map { case (k, item) => ConfigurationKey(k) -> toConfigItem(k, item) }.toMap + try onChange(ConfigurationUpdate(ConfigurationStoreName(storeNameStr), items)) + catch + case NonFatal(e) => + // Mirror the JVM impl: a throwing onChange is logged, never propagated into the SDK's + // stream loop (which would silently kill the subscription). java.util.logging is not in + // the Scala.js javalib, so console.warn stands in for Logger.log(WARNING, ...). + js.Dynamic.global.console.warn(s"Config subscription onChange callback threw: $e"): Unit + } + // WHAT: asInstanceOf erasing the callback's capture set ({this, onChange}). + // WHY: js.Function1 is a Scala-defined SAM, so CC tracks the closure's captures, but the SDK's + // SubscribeConfigurationCallback type (a ScalablyTyped alias of the same js.Function1 shape) mirrors the + // TypeScript callback type, which is necessarily capture-free — a JS interop boundary cannot carry capture + // annotations. + // WHY SAFE: the callback cannot outlive the capabilities it captures: it only runs while the + // SDK's stream loop is alive, the loop is torn down by stream.stop() (wired into the returned + // AutoCloseable), and that handle is itself ^{this}-bound so capture checking already prevents + // the subscription from escaping this capability's scope. Same erasure rationale as the + // AnyRef-erasure pattern documented in AGENTS.md. + val stream = JsAwait.await( + scope.grpcClient.configuration.subscribeWithMetadata( + storeNameStr, + keys.map(_.value).toJSArray, + toDict(metadata), + callback.asInstanceOf[SubscribeConfigurationCallback], + ), + ) + // stop() is async at runtime (an async arrow that aborts the stream and sends the explicit + // unsubscribe RPC) even though the SDK's TypeScript interface — and therefore the ScalablyTyped + // signature — says `stop(): void`. Awaiting the recovered promise makes close() synchronous + // like the JVM's `() => sub.dispose()`. + // + // WHAT: js.Dynamic call of stop() + asInstanceOf[js.Promise[Unit]] on its result. + // WHY: the ST-typed `stream.stop(): Unit` would discard the promise, so close() could return + // before the unsubscribe RPC went out — a behaviour change from the hand-verified facade + // (SubscribeConfigurationStream.stop is `stop = async () => {...}` in + // implementation/Client/GRPCClient/configuration.js; the TS interface under-promises). + // WHY SAFE: the runtime return value IS a Promise (verified in the SDK sources above); the + // dynamic call invokes the same member the typed call would, and the cast is the standard + // erased view of a known JS value. + () => JsAwait.await(stream.asInstanceOf[js.Dynamic].applyDynamic("stop")().asInstanceOf[js.Promise[Unit]]) + +@scala.caps.assumeSafe +private object ConfigurationCapabilityImpl: + + /** Build the dapr4s item from the SDK's `ConfigurationItem` (`createConfigurationType`, `utils/Client.util.js`). + * + * ScalablyTyped types `value`/`version`/`metadata` as required (the TS interface says so), but the reads below + * stay defensive type-tests: the values cross a JS boundary where proto3 string defaults make them `""` in + * practice, and an absent field must degrade to `""`/empty exactly like the JVM impl treats `null` — not to an + * undefined-as-String read. + */ + private def toConfigItem(k: String, item: SdkConfigurationItem): ConfigurationItem = + ConfigurationItem( + key = ConfigurationKey(k), + value = ConfigurationValue((item.value: Any) match + case s: String => s + case _ => ""), + version = ConfigurationVersion((item.version: Any) match + case s: String => s + case _ => ""), + metadata = (item.metadata: Any) match + case null => Map.empty[MetadataKey, MetadataValue] + case _ if js.isUndefined(item.metadata) => Map.empty[MetadataKey, MetadataValue] + case _ => + item.metadata.iterator.collect { case (mk, mv: String) => MetadataKey(mk) -> MetadataValue(mv) }.toMap, + ) diff --git a/src/js/internal/CryptoCapabilityImpl.scala b/src/js/internal/CryptoCapabilityImpl.scala new file mode 100644 index 0000000..e8dfb00 --- /dev/null +++ b/src/js/internal/CryptoCapabilityImpl.scala @@ -0,0 +1,92 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.collection.immutable.ArraySeq +import scala.scalajs.js +import scala.scalajs.js.typedarray.{Int8Array, Uint8Array} +import dapr4styped.daprDapr.typesCryptoRequestsMod.{DecryptRequest, EncryptRequest} +import dapr4styped.node.bufferMod.global.Buffer +import dapr4styped.std.ArrayBufferLike + +@scala.caps.assumeSafe +private[internal] final class CryptoCapabilityImpl( + scope: DaprCapabilityImpl, + val componentName: CryptoComponentName, +) extends CryptoCapability: + + import CryptoCapabilityImpl.* + + // Both operations go through scope.grpcClient (the lazily-created gRPC-protocol DaprClient): + // crypto is gRPC-only in the JS SDK — the HTTP implementation throws HTTPNotSupportedError. + // The buffered encrypt/decrypt overload is used: passing the payload as an ArrayBufferView makes + // the SDK collect the response stream into one Buffer (implementation/Client/GRPCClient/crypto.js + // processStream), the same whole-payload semantics as the JVM impl's Flux collectBytes(). + + def encrypt(keyName: CryptoKeyName, plaintext: ArraySeq[Byte], algorithm: KeyWrapAlgorithm): ArraySeq[Byte] = + val request = EncryptRequest( + componentName = componentName.value, + keyName = keyName.value, + keyWrapAlgorithm = toJsKeyWrapAlgorithm(algorithm), + ) + val result = JsAwait.await(scope.grpcClient.crypto.encrypt(toInt8Array(plaintext), request)) + fromBuffer(result) + + // The ciphertext embeds a reference to the wrapping key, so decryption needs only the component. + def decrypt(ciphertext: ArraySeq[Byte]): ArraySeq[Byte] = + val request = DecryptRequest(componentName = componentName.value) + val result = JsAwait.await(scope.grpcClient.crypto.decrypt(toInt8Array(ciphertext), request)) + fromBuffer(result) + +@scala.caps.assumeSafe +private object CryptoCapabilityImpl: + + import dapr4styped.daprDapr.daprDaprStrings + + /** The SDK's `keyWrapAlgorithm` type: ScalablyTyped's rendering of the TS string-literal union on `EncryptRequest` + * (`types/crypto/Requests.ts`). + */ + private type JsKeyWrapAlgorithm = daprDaprStrings.A256KW | daprDaprStrings.A128CBC | daprDaprStrings.A192CBC | + daprDaprStrings.A256CBC | daprDaprStrings.`RSA-OAEP-256` | daprDaprStrings.AES | daprDaprStrings.RSA + + /** WHAT: asInstanceOf conjuring the SDK's `keyWrapAlgorithm` string-literal union from the dapr4s value. + * + * WHY: the TypeScript type is a closed union of algorithm name literals (`"A256KW" | "A128CBC" | ... | "RSA"`), + * which ScalablyTyped renders as a union of phantom string traits no plain `String` conforms to. dapr4s's + * [[KeyWrapAlgorithm]] is deliberately open (an opaque String): the set of valid algorithms is a property of the + * configured crypto component, not of the client — the Java SDK models it as a plain string too. + * + * WHY SAFE: a TS string-literal union is erased to the string itself at runtime; the SDK passes the value verbatim + * into the protobuf request (`implementation/Client/GRPCClient/crypto.js`), and the sidecar/component validates it — + * an unsupported name fails the call exactly as it does on the JVM. + */ + private def toJsKeyWrapAlgorithm(algorithm: KeyWrapAlgorithm): JsKeyWrapAlgorithm = + algorithm.value.asInstanceOf[JsKeyWrapAlgorithm] + + private def toInt8Array(bytes: ArraySeq[Byte]): Int8Array = + val arr = bytes.toArray + val typed = new Int8Array(arr.length) + var i = 0 + while i < arr.length do + typed(i) = arr(i) + i += 1 + typed + + private def fromBuffer(buffer: Buffer[ArrayBufferLike]): ArraySeq[Byte] = + // The SDK returns a Node Buffer (a Uint8Array subclass, possibly a view into a larger pool + // allocation, hence the byteOffset-respecting copy). Bytes are copied out into a fresh + // Array[Byte]; unsafeWrapArray is then safe because the array never escapes. + // + // WHAT: asInstanceOf viewing the ScalablyTyped Buffer as the Scala.js-native js.typedarray.Uint8Array. + // WHY: ST's Buffer extends dapr4styped.std.Uint8Array, a structural re-typing of the ECMAScript class that + // exposes no element access (no @JSBracketAccess member), so the bytes cannot be read through the ST type. + // WHY SAFE: a Node Buffer IS an instance of the runtime Uint8Array class (Buffer extends Uint8Array is the + // documented Node contract), so the erased cast only switches to Scala.js's first-class typed-array view of + // the very same object. + val typed = buffer.asInstanceOf[Uint8Array] + val out = new Array[Byte](typed.length) + var i = 0 + while i < typed.length do + out(i) = typed(i).toByte + i += 1 + ArraySeq.unsafeWrapArray(out) diff --git a/src/js/internal/DaprAppServer.scala b/src/js/internal/DaprAppServer.scala new file mode 100644 index 0000000..04ce384 --- /dev/null +++ b/src/js/internal/DaprAppServer.scala @@ -0,0 +1,762 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import java.util.{ArrayList, HashMap as JHashMap} +import scala.concurrent.duration.{DurationInt, FiniteDuration} +import scala.jdk.CollectionConverters.* +import scala.scalajs.js +import scala.util.control.NonFatal +import org.scalablytyped.runtime.Instantiable1 +import dapr4styped.expressServeStaticCore.mod.{Express, Handler, ParamsDictionary, Request, Response} +import dapr4styped.node.globalsMod.global as NodeGlobals +import dapr4styped.node.httpMod.{IncomingMessage, Server, ServerResponse} +import dapr4styped.node.nodeColonnetMod.Socket +import dapr4styped.node.processMod.global.NodeJS.Signals +import dapr4styped.qs.mod.ParsedQs +import dapr4styped.std.Record + +/** HTTP server that serves the Dapr app-channel protocol from a [[DaprApp]] description — the Scala.js twin of the JVM + * `DaprAppServer`, identical route-for-route and status-code-for-status-code. + * + * The [[DaprApp]] is provided at construction time. Dispatch tables are built from it inside [[startAndBlock]]; until + * then the object is immutable. + * + * ==Why express, not the SDK's `DaprServer`== + * + * The JVM twin deliberately bypasses the Java SDK's server and hand-rolls the app-channel protocol on + * `com.sun.net.httpserver`; this class mirrors that design on express 4 (a dependency of `@dapr/dapr`, so always + * installed), typed by the ScalablyTyped `@types/express` conversion (plus the one hand-written shim, + * [[facade.ExpressModule]] — see its header for why). The JS SDK's `DaprServer` is unsuitable for the same reasons + * its Java counterpart was: its pub/sub callbacks strip the CloudEvent envelope (dapr4s hands the full envelope to + * subscription handlers) and its invocation listener constrains HTTP verbs (dapr4s accepts every verb and reports it + * in [[dapr4s.InvokeRequest]]). The SDK remains the backend for all '''outbound''' capabilities. + * + * ==Request model== + * + * A single `express.text` middleware (catch-all media type, effectively unlimited size — the JVM reads bodies + * unbounded too) makes `req.body` the raw body string, so JSON parsing stays in this file exactly like the JVM's + * `readBody` + Jackson. Every handler immediately enters a fresh `js.async { ... }` block before touching dapr4s + * dispatch — the per-request analogue of the JVM's virtual-thread-per-request executor, and a JSPI requirement: the + * express router invokes handlers from a JavaScript frame, and suspension (any capability call in user handler code is + * an orphan `js.await`) cannot cross a JS frame, so each request needs its own `js.async` entry (see + * [[ConfigurationCapabilityImpl.subscribe]] for the canonical explanation). + * + * ==Differences forced by express (documented, deliberate)== + * + * - The JVM's catch-all context dispatches pub/sub, binding, invocation and job paths for '''any''' HTTP verb; + * express routes here are registered with `app.all` to preserve exactly that (the OPTIONS probe daprd sends to + * input-binding routes must not 404, or the binding is never registered). Actor routes follow the protocol verbs + * (PUT method/remind/timer, DELETE deactivate) instead of the JVM's verb-agnostic prefix context. + * - Express matches `/dapr/subscribe` and `/dapr/config` exactly; the JVM's prefix-matching `HttpServer` would also + * answer sub-paths of them (never requested by the sidecar). + * - User route paths are passed to express verbatim; path strings containing `path-to-regexp` pattern characters + * (`:`/`*`/`(`) would be interpreted as patterns rather than the JVM's exact-string match. + * - `httpBacklog == 0` means "OS default" on the JVM; Node has no such sentinel, so 0 falls back to Node's default + * backlog (511) by omitting the argument. + */ +@scala.caps.assumeSafe +private[dapr4s] final class DaprAppServer(app: DaprApp): + + import DaprAppServer.* + + /** Build the dispatch tables, register every route on a fresh express app, start the workflow host if needed, bind + * the port, and suspend forever. + * + * "Blocking forever" is implemented by orphan-awaiting a promise that never resolves — the JS analogue of the JVM + * twin's `Thread.currentThread().join()`. The express server keeps the Node event loop alive regardless; the + * suspended Wasm stack exists to preserve `serve()`'s `Nothing` contract and to surface server `"error"` events + * (e.g. `EADDRINUSE`) as a thrown exception, like `HttpServer.create`'s synchronous `BindException` on the JVM. + * + * Shutdown mirrors the JVM hook in spirit: SIGINT/SIGTERM stop the listener, in-flight requests drain (Node's + * `server.close` callback), the workflow host closes, and after at most `shutdownGrace` the process exits — the + * bounded-drain semantics of the JVM's `server.stop(grace)`. One divergence: the JVM hook closes the workflow + * runtime only '''after''' the drain completes; here the close is initiated as soon as the listener stops accepting, + * because nothing can block a JS signal listener until the drain finishes. + */ + def startAndBlock( + port: Int, + daprCapability: DaprCapability, + sidecar: SidecarConfig, + shutdownGrace: FiniteDuration = 2.seconds, + httpBacklog: Int = 0, + actorConfig: ActorRuntimeConfig = ActorRuntimeConfig(), + ): Nothing = + + // ----------------------------------------------------------------------- + // Dispatch tables from DaprApp (the JVM keeps closures in JHashMaps to stay + // CC-opaque; here each closure goes straight into an express route handler, + // so only the /dapr/subscribe entry list and the actor-definition lookup + // table survive as tables). + // ----------------------------------------------------------------------- + + // For /dapr/subscribe — ordered list of (pubsubName, topic, route, deadLetterTopic) + // entries; the 4th element is "" (empty) when no dead-letter topic is configured. + val pubSubEntries: ArrayList[Array[String]] = ArrayList() + + // actorType → actorDefinition + val actorDefs: JHashMap[String, ActorDefinition] = JHashMap() + for actorDef <- app.actors do actorDefs.put(actorDef.actorType.value, actorDef) + + // ----------------------------------------------------------------------- + // Workflow/activity host (created only if needed, like the JVM runtime) + // ----------------------------------------------------------------------- + + val workflowHost: Option[WorkflowHost.Handle] = + if app.workflows.nonEmpty || app.activities.nonEmpty then + Some(WorkflowHost.start(app.workflows, app.activities, daprCapability, sidecar)) + else None + + // ----------------------------------------------------------------------- + // HTTP server — registration order encodes the JVM's dispatch precedence: + // exact framework paths, then the /actors prefix, then user routes in the + // JVM catch-all's check order (pub/sub, bindings, invocations, jobs), then + // the empty-404 fallback. + // ----------------------------------------------------------------------- + + val expressApp = facade.ExpressModule() + expressApp.mount( + facade.ExpressModule.text(new facade.ExpressTextOptions(`type` = "*/*", limit = Double.MaxValue)), + ) + + // Dapr sidecar calls GET /dapr/subscribe to discover pub/sub subscriptions. + // app.all + in-handler method check mirrors the JVM's 405 for non-GET verbs. + expressApp.allRoute( + "/dapr/subscribe", + (req, res, _) => + handleAsync(res, "/dapr/subscribe") { () => + if req.method.contains("GET") then + val arr = js.Array[js.Any]() + pubSubEntries.asScala.foreach: e => + val obj = js.Dictionary[js.Any]("pubsubname" -> e(0), "topic" -> e(1), "route" -> e(2)) + if e(3).nonEmpty then obj("deadLetterTopic") = e(3) + arr.push(obj) + sendJson(res, 200, js.JSON.stringify(arr)) + else sendEmpty(res, 405) + }, + ) + + // Dapr sidecar calls GET /dapr/config to discover hosted actor types. + // Served unconditionally (even with no actors), exactly like the JVM. + expressApp.allRoute( + "/dapr/config", + (req, res, _) => + handleAsync(res, "/dapr/config") { () => + if req.method.contains("GET") then sendJson(res, 200, actorConfigJson(actorDefs, actorConfig)) + else sendEmpty(res, 405) + }, + ) + + // Actor routes: PUT /actors/{type}/{id}/method/{name} + // PUT /actors/{type}/{id}/method/remind/{name} + // PUT /actors/{type}/{id}/method/timer/{name} + // DELETE /actors/{type}/{id} + // Registered only when actors exist, like the JVM's conditional "/actors" context. + // The remind/timer routes are registered first; their 5-segment paths cannot be + // claimed by the 4-segment {name} pattern (params never span a "/"). + if actorDefs.size() > 0 then + expressApp.putRoute( + "/actors/:actorType/:actorId/method/remind/:reminderName", + (req, res, _) => + handleAsync(res, req.path) { () => + dispatchActorReminder( + res, + param(req, "actorType"), + param(req, "actorId"), + param(req, "reminderName"), + readBody(req), + actorDefs, + sidecar, + ) + }, + ) + expressApp.putRoute( + "/actors/:actorType/:actorId/method/timer/:timerName", + (req, res, _) => + handleAsync(res, req.path) { () => + dispatchActorTimer( + res, + param(req, "actorType"), + param(req, "actorId"), + param(req, "timerName"), + readBody(req), + actorDefs, + sidecar, + ) + }, + ) + expressApp.putRoute( + "/actors/:actorType/:actorId/method/:methodName", + (req, res, _) => + handleAsync(res, req.path) { () => + val methodName = param(req, "methodName") + // Mirror the JVM's pattern guard (`methodName != "remind" && methodName != "timer"`): + // a 4-segment path ending in one of the reserved callback names is not a method + // invocation and falls through to 404 there. + if methodName == "remind" || methodName == "timer" then sendEmpty(res, 404) + else + dispatchActorMethod( + res, + param(req, "actorType"), + param(req, "actorId"), + methodName, + readBody(req), + actorDefs, + sidecar, + ) + }, + ) + expressApp.deleteRoute( + "/actors/:actorType/:actorId", + (_, res, _) => + handleAsync(res, "/actors") { () => + // Actor deactivation — no cleanup needed in our model (same as the JVM). + sendEmpty(res, 200) + }, + ) + + // Pub/sub delivery routes. app.all (not app.post) keeps the JVM's verb-agnostic + // catch-all dispatch — the sidecar only ever POSTs here. + for sub <- app.subscriptions do + val path = if sub.route.value.startsWith("/") then sub.route.value else "/" + sub.route.value + val handler = sub.rawHandler.asInstanceOf[CloudEvent[sub.Payload] => SubscriptionResult] + pubSubEntries.add( + Array(sub.pubsubName.value, sub.topic.value, path, sub.deadLetterTopic.map(_.value).getOrElse("")), + ) + val fn: String => SubscriptionResult = bodyJson => + parseCloudEvent(bodyJson, sub.codec, sub.pubsubName, sub.topic, handler) + expressApp.allRoute( + path, + erased((req, res, _) => + handleAsync(res, path) { () => + // Exceptions from fn propagate to handleAsync's catch, which sends a 500 response. + // Dapr retries the message on any non-2xx response, equivalent to SubscriptionResult.Retry. + val result = fn(readBody(req)) + val status = result match + case SubscriptionResult.Success => "SUCCESS" + case SubscriptionResult.Retry => "RETRY" + case SubscriptionResult.Drop => "DROP" + sendJson(res, 200, s"""{"status":"$status"}""") + }, + ), + ) + + // Input-binding routes. app.all is load-bearing here: on startup daprd probes each + // binding route with an OPTIONS request and registers the binding only on a non-404 + // answer — the JVM's verb-agnostic dispatch answers that probe (with a 500 decode + // failure, which daprd accepts), and so does this. + for bin <- app.bindings do + val path = "/" + bin.bindingName.value + val handler = bin.rawHandler.asInstanceOf[bin.Payload => Unit] + val fn: String => Unit = bodyJson => + bin.codec.decode(bodyJson) match + case Right(data) => handler(data) + case Left(e) => + throw RuntimeException( + s"Cannot decode binding payload for '${bin.bindingName.value}': ${e.getMessage}", + e, + ) + expressApp.allRoute( + path, + erased((req, res, _) => + handleAsync(res, path) { () => + fn(readBody(req)) + sendEmpty(res, 200) + }, + ), + ) + + // Service-invocation routes — every verb dispatches (app.all), and the verb is + // surfaced through InvokeRequest for withRequest handlers, exactly like the JVM. + for inv <- app.invokeRoutes do + val path = "/" + inv.methodName.value + val fn: (String, String) => String = + if inv.usesRequestEnvelope then + val handler = inv.rawHandler.asInstanceOf[InvokeRequest[inv.Req] => inv.Resp] + (methodStr, bodyJson) => + inv.reqCodec.decode(if bodyJson.isEmpty then "null" else bodyJson) match + case Right(req) => + inv.respCodec.encode(handler(InvokeRequest(inv.methodName, parseHttpMethod(methodStr), req))) + case Left(e) => + throw RuntimeException( + s"Cannot decode invocation request for '${inv.methodName.value}': ${e.getMessage}", + e, + ) + else + val handler = inv.rawHandler.asInstanceOf[inv.Req => inv.Resp] + (_, bodyJson) => + inv.reqCodec.decode(if bodyJson.isEmpty then "null" else bodyJson) match + case Right(req) => inv.respCodec.encode(handler(req)) + case Left(e) => + throw RuntimeException( + s"Cannot decode invocation request for '${inv.methodName.value}': ${e.getMessage}", + e, + ) + expressApp.allRoute( + path, + erased((req, res, _) => + // Server requests always carry a method; "" is the defensive fallback for the UndefOr + // the ScalablyTyped IncomingMessage type declares (it parses as Post downstream). + handleAsync(res, path)(() => sendJson(res, 200, fn(req.method.getOrElse(""), readBody(req)))), + ), + ) + + // Job trigger routes (POST /job/ from the sidecar; verb-agnostic like the JVM). + for job <- app.jobs do + val path = "/job/" + job.name.value + val handler = job.rawHandler.asInstanceOf[job.Payload => Unit] + val fn: String => Unit = bodyJson => + decodeJobPayload(bodyJson, job.codec) match + case Right(data) => handler(data) + case Left(e) => + throw RuntimeException( + s"Cannot decode job payload for '${job.name.value}': ${e.getMessage}", + e, + ) + expressApp.allRoute( + path, + erased((req, res, _) => + handleAsync(res, path) { () => + fn(readBody(req)) + sendEmpty(res, 200) + }, + ), + ) + + // Fallback for everything unrouted: the JVM catch-all's empty-bodied 404 + // (replacing express's default HTML "Cannot GET ..." page). + expressApp.mount((_, res, _) => sendEmpty(res, 404)) + + // ----------------------------------------------------------------------- + // Lifecycle + // ----------------------------------------------------------------------- + + val server: HttpServer = + if httpBacklog > 0 then + // WHAT: js.Dynamic invocation of listen(port, backlog, callback) + asInstanceOf on its result. + // WHY: Node's net.Server.listen argument normalisation takes a numeric second argument as the + // backlog (`toNumber(args[1])` in net.js), but @types/node — and therefore the ScalablyTyped + // surface — only declares the (port, hostname, backlog, callback) arity; passing a hostname to + // reach the typed backlog overload would change the bind address (a behaviour change from the + // hand facade, which used this same arity). + // WHY SAFE: the arity is documented stable Node behaviour, runtime-verified when the hand facade + // was written; the dynamic call invokes the very same `listen` member, and the result is the same + // http.Server the typed overloads return. + expressApp + .asInstanceOf[js.Dynamic] + .applyDynamic("listen")(port, httpBacklog, ((() => ()): js.Function0[Unit])) + .asInstanceOf[HttpServer] + else expressApp.listen(port.toDouble, () => ()) + + // SIGINT/SIGTERM take over Node's default terminate-on-signal: stop accepting, + // drain, close the workflow host, exit — force-exiting after shutdownGrace at + // the latest (the JVM hook's server.stop(grace) bounded drain). + def shutdown(): Unit = + closeServer(server)(() => NodeGlobals.process.exit(0)) + workflowHost.foreach { h => + // The JVM hook lets a workflow-runtime close failure kill the hook thread (it is + // merely logged by the JVM); an uncaught error in a Node signal listener would + // instead abort the process before the connection drain — so log and continue. + try h.close() + catch + case NonFatal(e) => + js.Dynamic.global.console.warn(s"dapr4s: workflow host close failed during shutdown: $e"): Unit + } + js.timers.setTimeout(shutdownGrace)(NodeGlobals.process.exit(0)): Unit + NodeGlobals.process.on(Signals.SIGINT, (_: Signals) => shutdown()): Unit + NodeGlobals.process.on(Signals.SIGTERM, (_: Signals) => shutdown()): Unit + + // Suspend this stack forever (the JS Thread.currentThread().join() — see the method + // scaladoc). The promise never resolves; it rejects only on a server "error" event, + // making a failed bind throw out of serve() like the JVM's BindException. + val serverFailure: js.Promise[Nothing] = new js.Promise[Nothing]((_, reject) => + server.on_error( + dapr4styped.node.nodeStrings.error, + (err: js.Error) => { + reject(err) + () + }, + ): Unit, + ) + try JsAwait.await(serverFailure) + catch + case NonFatal(e) => + // The workflow host started BEFORE the bind; if the bind (or the server) fails, this is + // the only exit path, and without the close the detached gRPC work-item stream would keep + // executing activities against a torn-down capability scope and keep the Node event loop + // alive after serve() has thrown. close() is idempotent and non-suspending (see + // WorkflowHost.Handle), so calling it here — inside a catch, outside any JS frame — is + // safe; its own failure is swallowed (NonFatal only) so the original server error stays + // the primary exception. The JVM twin shares this start-host-before-bind ordering but + // leaks its runtime via non-daemon threads on a bind failure — a candidate for a separate + // follow-up fix there. + workflowHost.foreach { h => + try h.close() + catch case NonFatal(_) => () + } + throw e + +@scala.caps.assumeSafe +private object DaprAppServer: + + // ------------------------------------------------------------------------- + // ScalablyTyped express plumbing + // ------------------------------------------------------------------------- + + /** The request/response instantiation dapr4s uses everywhere: express's own defaults (`ParamsDictionary` route + * params, `Any` bodies, `ParsedQs` query, `Record[String, Any]` locals) — the same parameters the ST `Handler` + * alias pins, spelled out so the helper signatures below stay readable. + */ + private type ExpressRequest = Request[ParamsDictionary, Any, Any, ParsedQs, Record[String, Any]] + private type ExpressResponse = Response[Any, Record[String, Any], Double] + + /** The `http.Server` instantiation `Application.listen` returns in the ST typing. */ + private type HttpServer = Server[ + Instantiable1[/* socket */ Socket, IncomingMessage], + Instantiable1[/* req */ Any, ServerResponse[IncomingMessage]], + ] + + /** Route/middleware registration helpers. + * + * ScalablyTyped renders every express registration method with explicit `[P, ResBody, ReqBody, ReqQuery, + * LocalsObj]` type parameters whose TS defaults do not survive conversion ("(This generic is meant to be passed + * explicitly.)" in the generated sources) — these extensions pin them once to the [[ExpressRequest]] instantiation + * so the call sites in `startAndBlock` stay as readable as with the old hand facade. `mount` uses the + * `@JSName("use")` explicit-generic variant for the same reason: the parameterless-path `use` overload is typed + * against `RouteParameters[String]` params, which a `Handler` (params = `ParamsDictionary`) does not satisfy under + * invariance. + */ + extension (app: Express) + private def allRoute(path: String, handler: Handler): Unit = + app.all[ParamsDictionary, Any, Any, ParsedQs, Record[String, Any]](path, handler): Unit + private def putRoute(path: String, handler: Handler): Unit = + app.put[ParamsDictionary, Any, Any, ParsedQs, Record[String, Any]](path, handler): Unit + private def deleteRoute(path: String, handler: Handler): Unit = + app.delete[ParamsDictionary, Any, Any, ParsedQs, Record[String, Any]](path, handler): Unit + private def mount(handler: Handler): Unit = + app.use_PResBodyReqBodyReqQueryLocalsObj[ParamsDictionary, Any, Any, ParsedQs, Record[String, Any]]( + handler, + ): Unit + + /** Stop accepting new connections; `callback` fires once all in-flight connections have closed. + * + * WHAT: asInstanceOf viewing the http server as the ST `net.Server` to reach `close`. + * + * WHY: the ScalablyTyped `@types/node` conversion drops `http.Server`'s `net.Server`/`EventEmitter` parents (the + * generated class extends only `StObject`), so `close` is missing from the [[HttpServer]] type even though every + * Node `http.Server` has it. + * + * WHY SAFE: `http.Server extends net.Server` is the documented, stable Node class hierarchy; the cast only + * switches the compile-time view of the very same object, and `close(cb)` is the same member the hand facade + * called (Node invokes the callback with an optional error, which the JVM-parity shutdown path ignores — + * `server.stop(grace)` has no error channel either). + */ + private def closeServer(server: HttpServer)(onClosed: () => Unit): Unit = + server.asInstanceOf[dapr4styped.node.netMod.Server].close((_: js.UndefOr[js.Error]) => onClosed()): Unit + + // ------------------------------------------------------------------------- + // Per-request async entry + // ------------------------------------------------------------------------- + + /** WHAT: `asInstanceOf` erasing an express handler lambda's inferred capture set (`Handler^` accepts any capturing + * handler; the cast forgets the set). + * + * WHY: the ST `Handler` is a `js.Function3` — a Scala-defined SAM, so CC tracks the closure's captures — and the + * route handlers for user-defined routes capture their dispatch closure (`fn`), which transitively reaches the + * enclosing `DaprAppServer`. The express registration signatures mirror express's JavaScript callback type, which + * is necessarily capture-free — a JS interop boundary cannot carry capture annotations. + * + * WHY SAFE: the handler cannot outlive what it captures: it only runs while the express server is listening, and the + * server lives for the entire process lifetime — `startAndBlock` never returns normally (shutdown exits the + * process), and its only exceptional exit (bind/server failure) stops the workflow runtime before unwinding, after + * which the failed server never invokes a handler. Same erasure rationale as + * `ConfigurationCapabilityImpl.subscribe`'s callback cast and the `AnyRef`-erasure pattern documented in AGENTS.md. + */ + private def erased(handler: Handler^): Handler = + handler.asInstanceOf[Handler] + + /** Run `dispatch` inside a fresh `js.async { ... }` entry and convert any non-fatal failure into the JVM twin's + * 500-with-error-JSON response (with the same warn-and-give-up fallback if even that send fails). + * + * The returned promise must never reach express as an unhandled rejection (Node terminates the process on unhandled + * rejections by default). Non-fatal throwables are handled exhaustively inside the block; the trailing `catch` + * covers the only remaining escape route — '''fatal''' throwables (`LinkageError` and friends, which `NonFatal` + * deliberately refuses to catch) — by logging them loudly instead of crashing the server, the moral equivalent of a + * JVM virtual thread dying with an uncaught-exception report while the server lives on. + */ + private def handleAsync(res: ExpressResponse, path: String)(dispatch: () => Unit): Unit = + val completion = js.async { + try dispatch() + catch + case NonFatal(e) => + try sendJson(res, 500, errorJson(e)) + catch + case NonFatal(e2) => + js.Dynamic.global.console.warn(s"dapr4s: failed to send error response for $path: $e2"): Unit + } + val onFatal: js.Function1[Any, Unit] = err => + js.Dynamic.global.console.error(s"dapr4s: fatal error escaped the request handler for $path: $err"): Unit + completion.`catch`[Unit](onFatal): Unit + + // ------------------------------------------------------------------------- + // Actor request dispatch + // ------------------------------------------------------------------------- + + private def dispatchActorMethod( + res: ExpressResponse, + actorType: String, + actorId: String, + methodName: String, + body: String, + actorDefs: JHashMap[String, ActorDefinition], + sidecar: SidecarConfig, + ): Unit = + val defn = actorDefs.get(actorType) + if defn == null then sendEmpty(res, 404) + else + val ctx = HttpActorContext(ActorType(actorType), ActorId(actorId), sidecar) + val routes = defn.build(ActorId(actorId), ctx) + val route = routes.methods.find(_.methodName.value == methodName).orNull + if route == null then sendEmpty(res, 404) + else + val handler = route.rawHandler.asInstanceOf[route.Req => route.Resp] + route.reqCodec.decode(if body.isEmpty then "null" else body) match + case Left(_) => sendEmpty(res, 400) + case Right(req) => sendJson(res, 200, route.respCodec.encode(handler(req))) + + private def dispatchActorReminder( + res: ExpressResponse, + actorType: String, + actorId: String, + reminderName: String, + body: String, + actorDefs: JHashMap[String, ActorDefinition], + sidecar: SidecarConfig, + ): Unit = + val defn = actorDefs.get(actorType) + if defn == null then sendEmpty(res, 404) + else + val ctx = HttpActorContext(ActorType(actorType), ActorId(actorId), sidecar) + val routes = defn.build(ActorId(actorId), ctx) + val route = routes.reminders.find(_.reminderName.value == reminderName).orNull + if route == null then + // Reminder delivered but no handler registered — acknowledge it silently + sendEmpty(res, 200) + else + val handler = route.rawHandler.asInstanceOf[route.Payload => Unit] + handler(decodeCallbackPayload(body, route.codec)) + sendEmpty(res, 200) + + private def dispatchActorTimer( + res: ExpressResponse, + actorType: String, + actorId: String, + timerName: String, + body: String, + actorDefs: JHashMap[String, ActorDefinition], + sidecar: SidecarConfig, + ): Unit = + val defn = actorDefs.get(actorType) + if defn == null then sendEmpty(res, 404) + else + val ctx = HttpActorContext(ActorType(actorType), ActorId(actorId), sidecar) + val routes = defn.build(ActorId(actorId), ctx) + val route = routes.timers.find(_.timerName.value == timerName).orNull + if route == null then sendEmpty(res, 200) + else + val handler = route.rawHandler.asInstanceOf[route.Payload => Unit] + handler(decodeCallbackPayload(body, route.codec)) + sendEmpty(res, 200) + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private def parseHttpMethod(s: String): HttpMethod = s.toUpperCase match + case "GET" => HttpMethod.Get + case "POST" => HttpMethod.Post + case "PUT" => HttpMethod.Put + case "PATCH" => HttpMethod.Patch + case "DELETE" => HttpMethod.Delete + case "HEAD" => HttpMethod.Head + case "OPTIONS" => HttpMethod.Options + case _ => HttpMethod.Post + + /** A named route parameter; defensively "" when absent (express always populates declared params). */ + private def param(req: ExpressRequest, name: String): String = + req.params.get(name).getOrElse("") + + /** The raw request body, mirroring the JVM `readBody`: the string captured by the `express.text` middleware, or "" + * for the requests body-parser skips (no body / no Content-Type), where `req.body` is its `{}` placeholder object. + * (The ST type of `req.body` is the `ReqBody = Any` type parameter, hence the type test rather than a cast.) + */ + private def readBody(req: ExpressRequest): String = + req.body match + case s: String => s + case _ => "" + + private def sendJson(res: ExpressResponse, code: Int, body: String): Unit = + res.status(code).`type`("application/json").send(body): Unit + + /** Status code with an empty body — the JVM's `exchange.sendResponseHeaders(code, -1)`. + * + * WHAT: js.Dynamic call of `res.end()`. + * + * WHY: `end()` lives on Node's `OutgoingMessage`/`stream.Writable`, whose parent links the ScalablyTyped + * `@types/node` conversion drops (the generated `ServerResponse` extends only `StObject`), so no typed member + * exists. The typed alternatives change behaviour: `res.send()`/`res.send("")` add `Content-Type`/`ETag` headers, + * `res.sendStatus(code)` writes the status text as a body. + * + * WHY SAFE: `end()` is the documented, stable way to finish a Node response without a body (inherited + * `ServerResponse.end`), exactly what the hand facade called; the dynamic call invokes the same member on the + * same object. + */ + private def sendEmpty(res: ExpressResponse, code: Int): Unit = + res.status(code).asInstanceOf[js.Dynamic].applyDynamic("end")(): Unit + + private def errorJson(e: Throwable): String = + val name = e.getClass.getSimpleName.nn + val error = if name.nonEmpty then name else e.getClass.getName.nn + val obj = js.Dictionary[js.Any]("error" -> error) + Option(e.getMessage).foreach(m => obj("error_description") = m) + js.JSON.stringify(obj) + + /** The `GET /dapr/config` actor-runtime JSON, field-for-field as the JVM emits it (Go-duration strings via + * [[dapr4s.DaprDuration.toGoString]], same key order, `entitiesConfig` only when non-empty). + */ + private def actorConfigJson(actorDefs: JHashMap[String, ActorDefinition], actorConfig: ActorRuntimeConfig): String = + val types = actorDefs.keySet().asScala.toList.sorted + val obj = js.Dictionary[js.Any]() + obj("entities") = js.Array(types*) + obj("actorIdleTimeout") = actorConfig.actorIdleTimeout.toGoString + obj("actorScanInterval") = actorConfig.actorScanInterval.toGoString + obj("drainOngoingCallTimeout") = actorConfig.drainOngoingCallTimeout.toGoString + obj("drainRebalancedActors") = actorConfig.drainRebalancedActors + obj("remindersStoragePartitions") = actorConfig.remindersStoragePartitions + obj("reentrancy") = js.Dictionary[js.Any]( + "enabled" -> actorConfig.reentrancy.enabled, + "maxStackDepth" -> actorConfig.reentrancy.maxStackDepth, + ) + if actorConfig.entitiesConfig.nonEmpty then + val ecArr = js.Array[js.Any]() + actorConfig.entitiesConfig.foreach: ec => + val entry = js.Dictionary[js.Any]() + entry("entities") = js.Array(ec.entities.map(_.value)*) + ec.actorIdleTimeout.foreach(v => entry("actorIdleTimeout") = v.toGoString) + ec.actorScanInterval.foreach(v => entry("actorScanInterval") = v.toGoString) + ec.drainOngoingCallTimeout.foreach(v => entry("drainOngoingCallTimeout") = v.toGoString) + ec.drainRebalancedActors.foreach(v => entry("drainRebalancedActors") = v) + ec.reentrancy.foreach: r => + entry("reentrancy") = js.Dictionary[js.Any]("enabled" -> r.enabled, "maxStackDepth" -> r.maxStackDepth) + ec.remindersStoragePartitions.foreach(v => entry("remindersStoragePartitions") = v) + ecArr.push(entry) + obj("entitiesConfig") = ecArr + js.JSON.stringify(obj) + + /** Decode the `data` field from a reminder/timer callback body. + * + * The Dapr sidecar sends `{"data":"base64-encoded-json","dueTime":"...","period":"..."}`. We base64-decode the + * `data` field and then JSON-decode it with the route's codec — same as the JVM twin. + * + * Structured slightly differently from the JVM's try/catch ordering because `js.JavaScriptException` (what a + * `JSON.parse` failure surfaces as) extends `RuntimeException`, so the JVM's "rethrow RuntimeException, wrap the + * rest" trick cannot distinguish parse failures here; the envelope parse gets its own wrap instead. Net behaviour is + * identical: parse failure → wrapped "Failed to parse callback body", decode failure → "Failed to decode callback + * payload", invalid base64 → raw `IllegalArgumentException` (a `RuntimeException` the JVM also rethrows unwrapped). + */ + private def decodeCallbackPayload[T](body: String, codec: JsonCodec[T]): T = + val dataB64 = + try + val env = js.JSON.parse(body) + if (env: Any) == null || js.typeOf(env) != "object" then "" + else + // WHAT: asInstanceOf on a js.JSON.parse result. + // WHY: JSON.parse is typed js.Any; we need property access on it. + // WHY SAFE: js.Dynamic is the untyped view of any JS value — the cast is a no-op at + // runtime, and the read below is type-tested before being trusted (see JsInterop.sdkFailureOf). + (env.asInstanceOf[js.Dynamic].selectDynamic("data"): Any) match + case s: String => s + case _ => "" // absent / null / non-string data — same as Jackson's asText("") fallback + catch case NonFatal(e) => throw RuntimeException("Failed to parse callback body", e) + val json = + if dataB64.isEmpty then "null" + else new String(java.util.Base64.getDecoder.nn.decode(dataB64).nn, "UTF-8") + codec.decode(json).fold(err => throw RuntimeException("Failed to decode callback payload", err), identity) + + /** Decode the payload of an inbound job trigger (`POST /job/`). + * + * Dapr delivers the job's stored data as the request body. Depending on the sidecar version and how the job was + * scheduled, the body is either the raw JSON payload or an envelope of the form `{"data": ...}`. We try the raw form + * first and fall back to unwrapping a top-level `data` field so both shapes work — same as the JVM twin. + */ + private def decodeJobPayload[T](body: String, codec: JsonCodec[T]): Either[JsonDecodeException, T] = + val json = if body.isEmpty then "null" else body + codec.decode(json) match + case r @ Right(_) => r + case Left(firstErr) => + try + val env = js.JSON.parse(json) + if (env: Any) != null && js.typeOf(env) == "object" then + // WHAT/WHY/WHY SAFE: same documented js.Dynamic view of a JSON.parse result as in + // decodeCallbackPayload above. + val data = env.asInstanceOf[js.Dynamic].selectDynamic("data") + if js.isUndefined(data) then Left(firstErr) + else + val inner = (data: Any) match + case s: String => s + case _ => js.JSON.stringify(data) + codec.decode(inner) + else Left(firstErr) + catch case NonFatal(_) => Left(firstErr) + + /** Parse a CloudEvent envelope and dispatch it, mirroring the JVM `parseCloudEvent` field-for-field: absent envelope + * fields fall back to the same defaults, a payload that fails to decode yields [[SubscriptionResult.Drop]], and a + * malformed body throws (→ 500 → sidecar retry). + * + * Field reads accept only JSON strings; Jackson's `asText("")` would additionally stringify scalar non-text nodes, + * which no CloudEvents-conformant sidecar ever sends. + */ + private def parseCloudEvent[T]( + bodyJson: String, + codec: JsonCodec[T], + defaultPubsubName: PubSubName, + defaultTopic: Topic, + handler: CloudEvent[T] => SubscriptionResult, + ): SubscriptionResult = + val env = js.JSON.parse(bodyJson) + // Jackson's readTree(...).get(name) yields null (absent) for any non-object envelope; + // mirror that with a guarded read so a non-object JSON body means "all fields absent" + // instead of a TypeError on property access. + val envDyn: Option[js.Dynamic] = + if (env: Any) == null || js.typeOf(env) != "object" then None + else + // WHAT/WHY/WHY SAFE: same documented js.Dynamic view of a JSON.parse result as in + // decodeCallbackPayload above. + Some(env.asInstanceOf[js.Dynamic]) + def rawField(name: String): js.Any = + envDyn.fold[js.Any](js.undefined)(_.selectDynamic(name)) + def textField(name: String): Option[String] = + (rawField(name): Any) match + case s: String => Some(s) + case _ => None + val data = + val v = rawField("data") + // JSON.stringify(undefined) is undefined (not a string), hence the explicit guard; + // a present-but-null data field stringifies to "null" like Jackson's NullNode. + if js.isUndefined(v) then "null" else js.JSON.stringify(v) + codec.decode(data) match + case Left(_) => SubscriptionResult.Drop + case Right(v) => + handler( + CloudEvent[T]( + id = CloudEventId(textField("id").filter(_.nonEmpty).getOrElse("unknown")), + source = CloudEventSource(textField("source").filter(_.nonEmpty).getOrElse("unknown")), + specVersion = CloudEventSpecVersion(textField("specversion").filter(_.nonEmpty).getOrElse("1.0")), + eventType = CloudEventType(textField("type").filter(_.nonEmpty).getOrElse("unknown")), + topic = textField("topic").map(Topic(_)).getOrElse(defaultTopic), + pubSubName = textField("pubsubname").map(PubSubName(_)).getOrElse(defaultPubsubName), + dataContentType = + ContentType(textField("datacontenttype").filter(_.nonEmpty).getOrElse("application/json")), + data = v, + ), + ) diff --git a/src/js/internal/DaprCapabilityImpl.scala b/src/js/internal/DaprCapabilityImpl.scala new file mode 100644 index 0000000..7c50dbb --- /dev/null +++ b/src/js/internal/DaprCapabilityImpl.scala @@ -0,0 +1,182 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import java.net.URI +import dapr4styped.daprDapr.anon.{PartialDaprClientOptions, PartialWorkflowClientOpti} +import dapr4styped.daprDapr.mod.{CommunicationProtocolEnum, DaprClient, DaprWorkflowClient} + +/** Lazily-created-client holder, the JS twin of the `AtomicReference[Client]` pattern in the JVM `Dapr.run` / + * `DaprCapabilityImpl`: [[dapr4s.Dapr.run]] owns the holder, [[DaprCapabilityImpl]] populates it on first use, and + * `run`'s `finally` block reads [[created]] to close only what was actually created. + * + * A plain `var` replaces the JVM's `AtomicReference.compareAndSet`: JavaScript is single-threaded, and + * [[getOrCreate]] performs no `js.await` between reading and writing the field — JSPI can only interleave other + * work at suspension points, so the read-check-write below is atomic by construction and there is no CAS race + * (hence also no "close the redundant client" branch the JVM needs). + */ +@scala.caps.assumeSafe +private[dapr4s] final class LazyClientRef[A]: + private var ref: Option[A] = None + + /** The client, if one was created. */ + def created: Option[A] = ref + + /** Return the already-created client or create, store, and return a new one. */ + def getOrCreate(create: () => A): A = + ref match + case Some(existing) => existing + case None => + val client = create() + ref = Some(client) + client + +/** Concrete implementation of [[dapr4s.DaprCapability]] backed by the Dapr JS SDK (`@dapr/dapr`) — the Scala.js twin + * of the JVM `DaprCapabilityImpl`. + * + * All interaction with the JS SDK is confined to this file and the individual `*CapabilityImpl` classes, through the + * ScalablyTyped-generated `dapr4styped.daprDapr` facades (see js-deps.scala). No JS types are visible in the public API. + * + * Lifecycle: [[dapr4s.Dapr.run]] owns all three clients; it creates the HTTP-protocol [[DaprClient]] and the two + * lazy refs, passes them here, and stops them in its `finally` block. The gRPC client (configuration and crypto are + * gRPC-only in the JS SDK) and the [[DaprWorkflowClient]] (gRPC, vendored durabletask) are created on first use via + * [[LazyClientRef.getOrCreate]], so `run` can close only what was actually created. + * + * Marked `@scala.caps.assumeSafe` so that safe-mode user code can use [[DaprCapability]] (implemented by this class) + * through the trait interface. + */ +@scala.caps.assumeSafe +private[dapr4s] final class DaprCapabilityImpl( + private[internal] val client: DaprClient, + private[internal] val sidecar: SidecarConfig, + private val grpcClientRef: LazyClientRef[DaprClient], + private val workflowClientRef: LazyClientRef[DaprWorkflowClient], +) extends DaprCapability: + + import DaprCapabilityImpl.* + + /** The gRPC-protocol client, created on first use — required by `configuration` and `crypto`, whose HTTP + * implementations in the JS SDK throw `HTTPNotSupportedError`. + */ + private[internal] def grpcClient: DaprClient = + grpcClientRef.getOrCreate(() => new DaprClient(grpcClientOptions(sidecar))) + + // WHY ^{this}: sub-capabilities extend ExclusiveCapability, so CC infers ^{fresh} for new + // instances. The trait declares ^{this} to prevent sub-capabilities from outliving `this`. + // Explicit ^{this} here overrides the ^{fresh} inference and satisfies the override check. + // The asInstanceOf cast then erases the capture set so internal Impl types stay package-private. + + def state(storeName: StateStoreName): StateCapability^{this} = + new StateCapabilityImpl(this, storeName).asInstanceOf[StateCapability] + + def publish(pubsubName: PubSubName): PublishCapability^{this} = + new PublishCapabilityImpl(this, pubsubName).asInstanceOf[PublishCapability] + + def invoke: InvokeCapability^{this} = + new InvokeCapabilityImpl(this).asInstanceOf[InvokeCapability] + + def secrets(storeName: SecretStoreName): SecretsCapability^{this} = + new SecretsCapabilityImpl(this, storeName).asInstanceOf[SecretsCapability] + + def configuration(storeName: ConfigurationStoreName): ConfigurationCapability^{this} = + new ConfigurationCapabilityImpl(this, storeName).asInstanceOf[ConfigurationCapability] + + def bindings(bindingName: BindingName): BindingsCapability^{this} = + new BindingsCapabilityImpl(this, bindingName).asInstanceOf[BindingsCapability] + + def lock(storeName: LockStoreName): LockCapability^{this} = + new LockCapabilityImpl(this, storeName).asInstanceOf[LockCapability] + + def actor(actorType: ActorType, actorId: ActorId): ActorCapability^{this} = + new ActorCapabilityImpl(actorType, actorId, sidecar).asInstanceOf[ActorCapability] + + def workflow: WorkflowCapability^{this} = + val wc = workflowClientRef.getOrCreate(() => new DaprWorkflowClient(workflowClientOptions(sidecar))) + new WorkflowCapabilityImpl(wc).asInstanceOf[WorkflowCapability] + + def crypto(componentName: CryptoComponentName): CryptoCapability^{this} = + new CryptoCapabilityImpl(this, componentName).asInstanceOf[CryptoCapability] + + // No jobs/conversation here: the Dapr JS SDK (@dapr/dapr 3.x) has no jobs or conversation API, + // so those factory methods exist only on the JVM DaprCapabilityPlatform parent trait — on this + // platform they are absent from DaprCapability at compile time (no runtime throw needed). + +@scala.caps.assumeSafe +private[dapr4s] object DaprCapabilityImpl: + + /** Split a `SidecarConfig` endpoint URI into the `(daprHost, daprPort)` string pair the JS SDK options take. + * + * The SDK reassembles them as `"daprHost:daprPort"` and parses the result with its `HttpEndpoint` / + * `GrpcEndpoint` network classes, both of which accept a scheme inside the host part — so for HTTP the URI scheme + * is kept (`http://host` + port) to preserve TLS/plaintext selection. + * + * For gRPC the scheme mapping is dictated by a quirk verified against a live sidecar: the workflow stack + * (`TaskHubGrpcWorker`/`TaskHubGrpcClient`) passes `GrpcEndpoint.endpoint` to grpc-js as the raw channel target, + * and `GrpcEndpoint.setEndpoint` renders non-`dns` schemes as `":host:port"` — a target grpc-js cannot + * resolve for `grpc`/`grpcs` (it falls back to `dns:grpc:host:port`, i.e. "Name resolution failed"). `grpcs` also + * never sets `tls` (`setTls` only honours `https:` or `?tls=true`). The only scheme spellings that work across + * '''both''' gRPC consumers (the workflow stack and `GRPCClient`, which reads just hostname/port/tls) are: + * - plaintext → '''no scheme''' (bare host): `GrpcEndpoint` then applies its default `dns` scheme, yielding the + * `"dns:host:port"` target grpc-js expects; + * - TLS → `https://host`: the one spelling that sets `tls = true` and still coerces the scheme to `dns` + * (`setScheme` logs a one-time deprecation `console.warn`, the price of the only working TLS form). + * + * A URI without an explicit port falls back to the parser defaults: 80/443 by scheme for HTTP (`HttpEndpoint`), + * 443 for gRPC (`URIParseConfig.DEFAULT_PORT`). + */ + private def hostAndPort(endpoint: URI, forGrpc: Boolean): (String, String) = + val rawScheme = endpoint.getScheme match + case null => "http" + case s => s + val scheme = + if forGrpc then + rawScheme match + case "http" | "grpc" => "" // bare host → GrpcEndpoint's default "dns" scheme (see scaladoc) + case "https" | "grpcs" => "https" // the only spelling that both sets tls and resolves (see scaladoc) + case other => other + else rawScheme + val host = endpoint.getHost match + case null => "localhost" + case h => h + val port = endpoint.getPort match + case -1 if forGrpc => 443 + case -1 if rawScheme == "https" => 443 + case -1 => 80 + case p => p + val hostPart = if scheme.isEmpty then host else s"$scheme://$host" + (hostPart, port.toString) + + /** Options for the default HTTP-protocol client, from `SidecarConfig.httpEndpoint`. + * + * Only the knobs the JS SDK exposes are mapped: endpoint, API token, and max body size (from + * `grpcMaxInboundMessageSizeBytes`, the closest dapr4s knob — the SDK applies `maxBodySizeMb` to both protocols). + * The remaining `SidecarConfig` fields are OkHttp/gRPC-Java transport settings with no JS equivalent and are + * ignored here (documented on [[dapr4s.Dapr]]). + */ + private[dapr4s] def httpClientOptions(sc: SidecarConfig): PartialDaprClientOptions = + val (host, port) = hostAndPort(sc.httpEndpoint, forGrpc = false) + val options = PartialDaprClientOptions() + .setDaprHost(host) + .setDaprPort(port) + .setCommunicationProtocol(CommunicationProtocolEnum.HTTP) + .setMaxBodySizeMb(sc.grpcMaxInboundMessageSizeBytes.toDouble / (1024 * 1024)) + sc.apiToken.foreach(t => options.setDaprApiToken(t.value): Unit) + options + + /** Options for the lazy gRPC-protocol client, from `SidecarConfig.grpcEndpoint`. */ + private[internal] def grpcClientOptions(sc: SidecarConfig): PartialDaprClientOptions = + val (host, port) = hostAndPort(sc.grpcEndpoint, forGrpc = true) + val options = PartialDaprClientOptions() + .setDaprHost(host) + .setDaprPort(port) + .setCommunicationProtocol(CommunicationProtocolEnum.GRPC) + .setMaxBodySizeMb(sc.grpcMaxInboundMessageSizeBytes.toDouble / (1024 * 1024)) + sc.apiToken.foreach(t => options.setDaprApiToken(t.value): Unit) + options + + /** Options for the lazy workflow client (gRPC, vendored durabletask), from `SidecarConfig.grpcEndpoint`. */ + private[internal] def workflowClientOptions(sc: SidecarConfig): PartialWorkflowClientOpti = + val (host, port) = hostAndPort(sc.grpcEndpoint, forGrpc = true) + val options = PartialWorkflowClientOpti().setDaprHost(host).setDaprPort(port) + sc.apiToken.foreach(t => options.setDaprApiToken(t.value): Unit) + options diff --git a/src/js/internal/HttpActorContext.scala b/src/js/internal/HttpActorContext.scala new file mode 100644 index 0000000..2350288 --- /dev/null +++ b/src/js/internal/HttpActorContext.scala @@ -0,0 +1,151 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.concurrent.duration.FiniteDuration +import scala.scalajs.js +import dapr4styped.node.globalsMod.global as NodeGlobals +import dapr4styped.undiciTypes.fetchMod.RequestInit + +/** [[ActorContext]] implementation backed by the Dapr actor HTTP API — the Scala.js twin of the JVM `HttpActorContext`, + * speaking the same routes with the same JSON bodies over the Node-global `fetch` (typed by the ScalablyTyped + * `@types/node` conversion) + [[JsAwait]] instead of `HttpURLConnection`. + * + * State reads/writes call `/v1.0/actors/{type}/{id}/state[/{key}]`. Reminder and timer registration/cancellation call + * the matching `/v1.0/actors/{type}/{id}/reminders/{name}` and `/v1.0/actors/{type}/{id}/timers/{name}` endpoints. + * + * Instantiated per actor invocation by [[DaprAppServer]]; immutable once constructed. + * + * One deliberate difference from the JVM twin's constructor: it takes the whole [[SidecarConfig]] rather than a bare + * endpoint URI, because the JS raw-fetch precedent ([[ActorCapabilityImpl]]) routes the `dapr-api-token` header + * through `SidecarConfig.apiToken` — so this context authenticates to a token-protected sidecar, where the JVM twin + * currently sends no token. + * + * @param sidecar + * sidecar connection settings; `httpEndpoint` is the base URL of the actor HTTP API (e.g. `"http://localhost:3500"`) + * and `apiToken` (when set) is sent as the `dapr-api-token` header on every call + */ +@scala.caps.assumeSafe +private[internal] final class HttpActorContext( + private val actorType: ActorType, + private val actorId: ActorId, + private val sidecar: SidecarConfig, +) extends ActorContext: + + import HttpActorContext.* + + // ---- URL helpers ----------------------------------------------------------- + + private def base: String = ActorCapabilityImpl.httpBase(sidecar) + + // Percent-encoded like every raw-fetch URL — see ActorCapabilityImpl.urlSegment. + private def actorPrefix: String = + val tpe = ActorCapabilityImpl.urlSegment(actorType.value) + val id = ActorCapabilityImpl.urlSegment(actorId.value) + s"$base/v1.0/actors/$tpe/$id" + + private def stateUrl(key: ActorStateKey): String = + s"$actorPrefix/state/${ActorCapabilityImpl.urlSegment(key.value)}" + + private def bulkStateUrl: String = + s"$actorPrefix/state" + + private def reminderUrl(name: ReminderName): String = + s"$actorPrefix/reminders/${ActorCapabilityImpl.urlSegment(name.value)}" + + private def timerUrl(name: TimerName): String = + s"$actorPrefix/timers/${ActorCapabilityImpl.urlSegment(name.value)}" + + // ---- State ----------------------------------------------------------------- + + def get[T: JsonCodec](key: ActorStateKey): Option[T] = + val url = stateUrl(key) + val init = RequestInit().setMethod("GET").setHeaders(ActorCapabilityImpl.baseHeaders(sidecar)) + val response = JsAwait.await(NodeGlobals.fetch(url, init)) + // Consume the body BEFORE branching on the status: the always-consume invariant (see postJson) + // also covers the 204/404 early return, or the unread (empty) body would pin the connection in + // Node fetch's keep-alive pool. + val text = JsAwait.await(response.text()) + val code = response.status + if code == 204 || code == 404 then None + // The JVM twin reads conn.getInputStream here, which throws IOException for any other + // error status; mirror that by failing loudly instead of silently decoding an error body. + else if code >= 400 then throw new RuntimeException(s"Dapr API error $code at $url: $text") + else summon[JsonCodec[T]].decode(text).toOption + + def set[T: JsonCodec](key: ActorStateKey, value: T): Unit = + val requestInner = js.Dictionary[js.Any]( + "key" -> key.value, + "value" -> js.JSON.parse(summon[JsonCodec[T]].encode(value)), + ) + val requestObj = js.Dictionary[js.Any]("operation" -> "upsert", "request" -> requestInner) + postJson(sidecar, bulkStateUrl, js.JSON.stringify(js.Array[js.Any](requestObj))) + + def remove(key: ActorStateKey): Unit = + val requestInner = js.Dictionary[js.Any]("key" -> key.value) + val requestObj = js.Dictionary[js.Any]("operation" -> "delete", "request" -> requestInner) + postJson(sidecar, bulkStateUrl, js.JSON.stringify(js.Array[js.Any](requestObj))) + + // ---- Reminders ------------------------------------------------------------- + + def registerReminder[T: JsonCodec]( + name: ReminderName, + data: T, + dueTime: FiniteDuration, + period: Option[FiniteDuration] = None, + ): Unit = + postJson(sidecar, reminderUrl(name), schedulePayload(data, dueTime, period)) + + def unregisterReminder(name: ReminderName): Unit = + deleteRequest(sidecar, reminderUrl(name)) + + // ---- Timers ---------------------------------------------------------------- + + def registerTimer[T: JsonCodec]( + name: TimerName, + data: T, + dueTime: FiniteDuration, + period: Option[FiniteDuration] = None, + ): Unit = + postJson(sidecar, timerUrl(name), schedulePayload(data, dueTime, period)) + + def unregisterTimer(name: TimerName): Unit = + deleteRequest(sidecar, timerUrl(name)) + +@scala.caps.assumeSafe +private object HttpActorContext: + + /** The JSON body shared by reminder and timer registration, exactly as the JVM twin builds it: the payload encoded by + * its [[JsonCodec]], UTF-8 base64'd into `data`, with `dueTime`/`period` in ISO-8601 duration form (the JVM's + * `java.time.Duration.ofNanos(...).toString`, e.g. `"PT2S"` — Dapr accepts both ISO-8601 and Go duration strings). + */ + private def schedulePayload[T: JsonCodec](data: T, dueTime: FiniteDuration, period: Option[FiniteDuration]): String = + val dataJson = summon[JsonCodec[T]].encode(data) + val dataBytes = dataJson.getBytes("UTF-8").nn + val dataBase64 = java.util.Base64.getEncoder.nn.encodeToString(dataBytes).nn + val fields = js.Dictionary[js.Any]("dueTime" -> toIso(dueTime), "data" -> dataBase64) + period.foreach(p => fields("period") = toIso(p)) + js.JSON.stringify(fields) + + private def toIso(d: FiniteDuration): String = + java.time.Duration.ofNanos(d.toNanos).toString + + // ---- HTTP helpers ---------------------------------------------------------- + + /** POST `body` as JSON; throw on HTTP >= 400 with the same message shape as the JVM twin's `postJson` (`"Dapr API + * error $code at $url: $errBody"`). The response body is always consumed (Node's fetch keeps the connection alive + * until the body is read). + */ + private def postJson(sidecar: SidecarConfig, url: String, body: String): Unit = + val init = RequestInit().setMethod("POST").setHeaders(ActorCapabilityImpl.baseHeaders(sidecar)).setBody(body) + val response = JsAwait.await(NodeGlobals.fetch(url, init)) + val text = JsAwait.await(response.text()) + if response.status >= 400 then throw new RuntimeException(s"Dapr API error ${response.status} at $url: $text") + + /** DELETE with the status deliberately ignored, mirroring the JVM twin (`val _ = conn.getResponseCode`): + * unregistering a missing reminder/timer is a documented no-op. + */ + private def deleteRequest(sidecar: SidecarConfig, url: String): Unit = + val init = RequestInit().setMethod("DELETE").setHeaders(ActorCapabilityImpl.baseHeaders(sidecar)) + val response = JsAwait.await(NodeGlobals.fetch(url, init)) + val _ = JsAwait.await(response.text()) diff --git a/src/js/internal/InvokeCapabilityImpl.scala b/src/js/internal/InvokeCapabilityImpl.scala new file mode 100644 index 0000000..21fb7be --- /dev/null +++ b/src/js/internal/InvokeCapabilityImpl.scala @@ -0,0 +1,144 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.scalajs.js +import JsInterop.* +// The TYPE comes from the deep module (types are erased — no import is emitted), but the VALUES +// must be read off the "@dapr/dapr" root re-export: ScalablyTyped's deep-module specifiers +// (`@dapr/dapr/enum/HttpMethod.enum`) carry no `.js` extension and `@dapr/dapr` has no `exports` +// map, so Node ESM (the Wasm/JSPI production target) cannot resolve them — ERR_MODULE_NOT_FOUND +// at load time (runtime-verified). Only root re-exports may be referenced in value position. +import dapr4styped.daprDapr.enumHttpMethodDotenumMod.HttpMethod as SdkHttpMethod +import dapr4styped.daprDapr.mod.HttpMethod as SdkHttpMethods +import dapr4styped.daprDapr.typesInvokerOptionsDottypeMod.InvokerOptions +import dapr4styped.node.globalsMod.global as NodeGlobals +import dapr4styped.undiciTypes.fetchMod.RequestInit + +@scala.caps.assumeSafe +private object InvokeCapabilityImpl: + + /** Invoke over the raw sidecar HTTP API (`{verb} /v1.0/invoke/{appId}/method/{method}`), bypassing the SDK. + * + * Exists because the SDK cannot transmit JS-falsy payloads: `HTTPClient.execute` only attaches a body when + * `if (params?.body)` is truthy (`node_modules/@dapr/dapr/implementation/Client/HTTPClient/HTTPClient.js`), so the + * JSON documents `0`, `false`, `null` and `""` would be sent with an '''empty''' body. Same raw-fetch precedent as + * [[ActorCapabilityImpl]]; the pre-encoded JSON string goes on the wire verbatim with + * `Content-Type: application/json` (no SDK serializer involved, so no double encoding) and metadata as extra headers + * like the SDK path. The method name is encoded segment-by-segment: Dapr method names may legitimately be + * multi-segment routes (`api/orders`), where the `/` must survive as a path separator while everything else is + * percent-encoded. The response is handled like the SDK path: non-2xx throws (raw-fetch message shape), an empty + * body reaches the codec as `null` (the JVM's empty `Mono` → `null` bytes), and a non-empty body '''is''' already + * the JSON document, decoded directly. + */ + private def rawInvoke[Resp: JsonCodec]( + sidecar: SidecarConfig, + appId: AppId, + method: InvokeMethodName, + json: String, + httpMethod: HttpMethod, + metadata: Map[MetadataKey, MetadataValue], + ): Resp = + import ActorCapabilityImpl.urlSegment + val methodPath = method.value.split("/").nn.map(s => urlSegment(s.nn)).mkString("/") + val base = ActorCapabilityImpl.httpBase(sidecar) + val url = s"$base/v1.0/invoke/${urlSegment(appId.value)}/method/$methodPath" + // baseHeaders supplies Content-Type: application/json + dapr-api-token; metadata adds headers + // on top, mirroring the SDK path's InvokerOptions.headers. A metadata key that collides with a + // base header REPLACES it (drop the base pair first): filling fetch Headers from a pairs array + // APPENDS, and duplicate names are combined as "v1, v2" — which would corrupt Content-Type or + // dapr-api-token instead of letting the caller override them. + val headers = ActorCapabilityImpl + .baseHeaders(sidecar) + .filterNot(pair => metadata.keys.exists(_.value.equalsIgnoreCase(pair(0)))) + metadata.foreach { case (k, v) => headers.push(js.Array(k.value, v.value)): Unit } + // fetch only auto-uppercases the six spec-listed methods (notably NOT "patch"), so uppercase + // explicitly like the SDK does (`params?.method.toLocaleUpperCase()`). + val init = RequestInit().setMethod(toJsMethod(httpMethod).toUpperCase).setHeaders(headers).setBody(json) + val response = JsAwait.await(NodeGlobals.fetch(url, init)) + // Always consume the body (Node fetch keep-alive invariant — see HttpActorContext.postJson). + val text = JsAwait.await(response.text()) + if response.status >= 400 then throw new RuntimeException(s"Dapr API error ${response.status} at $url: $text") + JsonCodec.decodeOrThrow[Resp](if text.isEmpty then null else text) + + /** dapr4s [[HttpMethod]] → the SDK's lowercase `HttpMethod` string values (`enum/HttpMethod.enum.js`). The + * intersection with `String` is how ScalablyTyped types the enum's members (a TS string enum), and it keeps plain + * string operations (`toUpperCase` in [[rawInvoke]]) available. + * + * WHAT (Head/Options branches): `asInstanceOf` conjuring an `SdkHttpMethod` from a string literal. + * + * WHY: the SDK enum only declares get/delete/post/put/patch — there are no HEAD/OPTIONS members to reference — but + * dapr4s supports those verbs. + * + * WHY SAFE: the runtime representation of the TS string enum IS the string; `"head"`/`"options"` flow verbatim into + * `HTTPClient.execute`, which upper-cases the value and hands it to fetch (`clientOptions.method = + * params?.method.toLocaleUpperCase()`) — verified in the SDK sources, same contract as the five declared members. + */ + private def toJsMethod(m: HttpMethod): SdkHttpMethod & String = + m match + case HttpMethod.Get => SdkHttpMethods.GET + case HttpMethod.Post => SdkHttpMethods.POST + case HttpMethod.Put => SdkHttpMethods.PUT + case HttpMethod.Delete => SdkHttpMethods.DELETE + case HttpMethod.Patch => SdkHttpMethods.PATCH + case HttpMethod.Head => "head".asInstanceOf[SdkHttpMethod & String] + case HttpMethod.Options => "options".asInstanceOf[SdkHttpMethod & String] + + /** WHAT: asInstanceOf viewing a parsed JSON value (`js.Any`) as `js.Object`, the SDK's invoke-data type. + * + * WHY: the TypeScript signature (`data?: object`) is narrower than the runtime contract — with the explicit + * application/json header, `serializeHttp` JSON.stringify-s '''any''' JS value, and dapr4s must pass JSON scalar + * documents (truthy numbers/booleans/strings) down this path. Only JS-falsy values are excluded (they take the + * rawInvoke bypass). + * + * WHY SAFE: erased, zero-cost view change; every value here came out of `JSON.parse`, and the SDK's only operation + * on it is the single `JSON.stringify` that puts our original document back on the wire. Same rationale as + * `PublishCapabilityImpl.asPublishData`. + */ + private def asInvokeData(parsed: js.Any): js.Object = + parsed.asInstanceOf[js.Object] + +@scala.caps.assumeSafe +private[internal] final class InvokeCapabilityImpl( + scope: DaprCapabilityImpl, +) extends InvokeCapability: + + import InvokeCapabilityImpl.* + + def invoke[Req: JsonCodec]( + appId: AppId, + method: InvokeMethodName, + data: Req, + httpMethod: HttpMethod = HttpMethod.Post, + metadata: Map[MetadataKey, MetadataValue] = Map.empty, + )[Resp: JsonCodec]: Resp = + val json = summon[JsonCodec[Req]].encode(data) + val parsed = parseJson(json) + // Falsy payloads (0, false, null, "") cannot take the SDK path: HTTPClient.execute attaches + // the request body only behind a JS truthiness check (`if (params?.body)` — implementation/ + // Client/HTTPClient/HTTPClient.js), so the body would be silently dropped. They go through + // rawInvoke (raw fetch) instead — see JsInterop.isFalsyJson. + if isFalsyJson(parsed) then rawInvoke[Resp](scope.sidecar, appId, method, json, httpMethod, metadata) + else + // Metadata maps to extra HTTP headers (InvokerOptions.headers — the JVM impl's invokeMethod + // metadata are headers too). The explicit Content-Type: application/json header makes the + // SDK's serializeHttp JSON.stringify the parsed value rather than inferring text/plain for + // JSON scalar payloads — same wire-format reasoning as PublishCapabilityImpl.publish. + val headers = toDict[Any](metadata) + headers("Content-Type") = "application/json" + val response = JsAwait.await( + scope.client.invoker.invoke( + appId.value, + method.value, + toJsMethod(httpMethod), + asInvokeData(parsed), + InvokerOptions().setHeaders(headers), + ), + ) + // An empty response body surfaces as "" (HTTPClient.execute's tryParseJson); jsonStringOrNull + // maps it to null so the codec sees the same input as on the JVM (empty Mono → null bytes). + JsonCodec.decodeOrThrow[Resp](jsonStringOrNull(response)) + + def invoke[Resp: JsonCodec](appId: AppId, method: InvokeMethodName): Resp = + val response = JsAwait.await(scope.client.invoker.invoke(appId.value, method.value, SdkHttpMethods.GET)) + JsonCodec.decodeOrThrow[Resp](jsonStringOrNull(response)) diff --git a/src/js/internal/JsAwait.scala b/src/js/internal/JsAwait.scala new file mode 100644 index 0000000..0d5e329 --- /dev/null +++ b/src/js/internal/JsAwait.scala @@ -0,0 +1,47 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import scala.scalajs.js + +/** The single place where the JS internal layer bridges `js.Promise` to a synchronous return value. + * + * This is the Scala.js analogue of `MonoOps.awaitResult()` on the JVM: every capability implementation funnels its one + * asynchronous boundary through this object, so the rest of the codebase stays in direct style. + * + * ==How it works (Wasm + JSPI)== + * + * `js.await` outside a lexically enclosing `js.async` block is an "orphan await", enabled by the + * `scala.scalajs.js.wasm.JSPI.allowOrphanJSAwait` import below. On the experimental WebAssembly backend, JavaScript + * Promise Integration (JSPI) '''suspends the entire Wasm stack''' at this point and returns control to the event loop + * — no thread is blocked (there are none), inbound work keeps being served, and the stack resumes when the promise + * settles. This is the exact architectural analogue of a virtual thread parking in `CompletableFuture.get()` on the + * JVM. + * + * Requirements this imposes on callers (documented on [[dapr4s.Dapr]]): + * - somewhere up the '''Scala''' call stack there must be a dynamically enclosing `js.async { ... }` with no + * JavaScript frame in between (otherwise the engine throws `WebAssembly.SuspendError`) — that is why `Dapr.run` + * callers enter `js.async` once at the program edge, and why every SDK callback that re-enters dapr4s code opens a + * fresh `js.async`; + * - the program must be linked with the experimental WebAssembly backend (`jsEmitWasm`, ES modules) and run on a + * JSPI-capable engine (Node 25+, or Node 23/24 with `--experimental-wasm-jspi`). + * + * ==Plain-JS backend: link-time error by design== + * + * Orphan awaits only '''link''' when targeting WebAssembly (see the scaladoc of `JSPI.allowOrphanJSAwait`). On the + * plain JS backend, code paths reaching this object fail at link time, not at runtime — a deliberate, clean failure + * mode: the pure parts of dapr4s (models, codecs, derivation) still link on plain JS, and only actually touching a + * capability requires the Wasm backend. + * + * ==Rejected promises== + * + * `js.await` rethrows a rejected promise's value; a rejected JS `Error` therefore surfaces in Scala as + * `js.JavaScriptException(error)` (a `RuntimeException`, matched by `NonFatal`). Call sites that need typed exceptions + * (ETag conflicts, workflow wait timeouts) pattern-match that exception and translate — see `JsInterop.sdkFailureOf` + * and `WorkflowCapabilityImpl.waitForCompletion`. + */ +@scala.caps.assumeSafe +private[dapr4s] object JsAwait: + import scala.scalajs.js.wasm.JSPI.allowOrphanJSAwait + + /** Suspend until `p` settles; return its value or rethrow its rejection as `js.JavaScriptException`. */ + def await[A](p: js.Promise[A]): A = js.await(p) diff --git a/src/js/internal/JsInterop.scala b/src/js/internal/JsInterop.scala new file mode 100644 index 0000000..64b90d3 --- /dev/null +++ b/src/js/internal/JsInterop.scala @@ -0,0 +1,130 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.scalajs.js + +import org.scalablytyped.runtime.StringDictionary + +/** JSON/string/error bridging between dapr4s ([[JsonCodec]] works on JSON strings) and the Dapr JS SDK (which works on + * parsed JS values). The JS analogue of `internal/Json.scala` + `internal/NullOps.scala` on the JVM. + * + * The bridge is `js.JSON.parse` on the way out and `js.JSON.stringify` on the way back. One consequence to be aware + * of: a JSON document round-tripped through JS values is re-serialized in canonical JS form (insignificant whitespace + * dropped, `1.0` becomes `1`, and integers beyond 2^53 lose precision) — semantically equal JSON for everything except + * >53-bit integers, which JavaScript itself cannot represent. + */ +@scala.caps.assumeSafe +private[internal] object JsInterop: + + /** Parse a dapr4s-encoded JSON string into a JS value to hand to the SDK. */ + def parseJson(json: String): js.Any = js.JSON.parse(json) + + /** View a loosely-typed SDK value as `js.Any` so the `js.JSON` API accepts it. + * + * WHAT: `asInstanceOf[js.Any]` on a `scala.Any`-typed value. + * + * WHY: the ScalablyTyped facades model TypeScript `any` as `scala.Any` and TS unions as Scala 3 unions (e.g. + * `state.get`'s `KeyValueType | String`); neither conforms to `js.Any`, which `js.JSON.stringify` requires — even + * though the runtime value behind them is always a plain JavaScript value. + * + * WHY SAFE: every value passed here is read off a `dapr4styped.*` API, i.e. produced by JavaScript code, so it IS a + * JavaScript value at runtime. The cast is a compile-time view change only (`asInstanceOf` to a JS type is unchecked + * and erased) and cannot fail or change the value. + */ + def asJsAny(v: Any): js.Any = v.asInstanceOf[js.Any] + + /** True when a parsed JSON document is a JS-'''falsy''' value: `null`, `false`, `0` (incl. `-0`), or `""`. (Empty + * objects/arrays are truthy in JS and correctly excluded; `undefined` is checked defensively even though + * `JSON.parse` can never produce it.) + * + * Why this matters: the SDK's `HTTPClient.execute` guards the request body with a plain truthiness check — + * `if (params?.body)` in `node_modules/@dapr/dapr/implementation/Client/HTTPClient/HTTPClient.js` — so handing it a + * falsy payload silently produces an '''empty''' request body on the wire. Callers that pass parsed payloads to the + * SDK ([[PublishCapabilityImpl]], [[InvokeCapabilityImpl]]) must detect this case and bypass the SDK with a raw + * fetch instead. + */ + def isFalsyJson(v: js.Any): Boolean = + js.isUndefined(v) || ((v: Any) match + case null => true + case b: Boolean => !b + case d: Double => d == 0.0 // every JS number pattern-matches as Double on Scala.js; covers 0 and -0 + case s: String => s.isEmpty + case _ => false) + + /** Convert an SDK response value back into the JSON string (or `null`) that [[JsonCodec.decode]] expects. + * + * `HTTPClient.execute` returns the response body after `tryParseJson`: an empty body comes back as the empty string + * `""` (because `JSON.parse("")` throws and the raw text is substituted), a JSON body as the parsed value. Absent + * (`undefined`/`null`) and empty-body responses map to `null`, mirroring the JVM impls where an empty `Mono` yields + * a `null` byte array. + * + * One documented, accepted divergence from the JVM (which sees the raw response bytes): a response document that + * '''is''' the empty string (the two-byte JSON document `""`) parses to the empty JS string — the very same value + * `tryParseJson` substitutes for an empty body — so post-SDK the two cases are indistinguishable and both map to + * `null`/`None` here. + */ + def jsonStringOrNull(v: js.Any): String | Null = + if isAbsent(v) then null else js.JSON.stringify(v) + + /** True when the SDK response denotes "no payload": `undefined`, `null`, or the empty string (the `tryParseJson` + * artifact for an empty HTTP body — which, see [[jsonStringOrNull]], also swallows a response document that is the + * JSON empty string `""`). Takes `scala.Any` so the ScalablyTyped response types (TS `any` → `scala.Any`, TS unions + * → Scala unions) can be tested without a cast — every check below is JS-value-agnostic. + */ + def isAbsent(v: Any): Boolean = + js.isUndefined(v) || (v == null) || (v match + case s: String => s.isEmpty + case _ => false) + + /** dapr4s metadata map → the string dictionary the ScalablyTyped SDK options take. + * + * The element type is generic because the generated facades want two shapes for what is one runtime concept: + * `KeyValueType = StringDictionary[Any]` (pub/sub options, configuration metadata, invoker headers) and + * `IRequestMetadata = StringDictionary[String]` (state-save metadata). `StringDictionary` is invariant, so the + * expected type at the call site selects `V`; the runtime object is the same plain string-valued JS object either + * way. + */ + def toDict[V >: String](metadata: Map[MetadataKey, MetadataValue]): StringDictionary[V] = + StringDictionary(metadata.toSeq.map { case (k, v) => k.value -> (v.value: V) }*) + + /** A parsed SDK HTTP API failure. + * + * @param status + * the HTTP status code returned by the sidecar + * @param errorMsg + * the raw response body (typically the sidecar's `{"errorCode": ..., "message": ...}` JSON) + */ + final case class SdkHttpFailure(status: Int, errorMsg: String) + + /** Decode the SDK's HTTP error convention from a rejected/soft-failure `js.Error`. + * + * `HTTPClient.execute` rejects non-2xx/3xx responses with a plain `Error` whose '''message''' is + * `JSON.stringify({error: statusText, error_msg: bodyText, status: number})` — there is no typed error hierarchy. + * Returns `None` when the message does not follow that convention (network errors, gRPC `ConnectError`s, ...). + */ + def sdkFailureOf(error: js.Error): Option[SdkHttpFailure] = + try + val parsed = js.JSON.parse(error.message).asInstanceOf[js.Dynamic] + // WHAT: asInstanceOf on a js.JSON.parse result. + // WHY: JSON.parse is typed js.Dynamic-producing js.Any; we need property access on it. + // WHY SAFE: js.Dynamic is the untyped view of any JS value — the cast is a no-op at runtime, and the + // property reads below are guarded (typeof checks) before being trusted. + val status = parsed.selectDynamic("status") + val errorMsg = parsed.selectDynamic("error_msg") + (status: Any) match + case s: Double => + val msg = (errorMsg: Any) match + case m: String => m + case _ => "" + Some(SdkHttpFailure(s.toInt, msg)) + case _ => None + catch + // JSON.parse throws SyntaxError for non-JSON messages — that simply means "not the SDK HTTP convention". + case _: js.JavaScriptException => None + + /** [[sdkFailureOf]] for exceptions caught in Scala: unwrap `js.JavaScriptException` carrying a JS `Error`. */ + def sdkFailureOf(t: Throwable): Option[SdkHttpFailure] = + t match + case js.JavaScriptException(e: js.Error) => sdkFailureOf(e) + case _ => None diff --git a/src/js/internal/LockCapabilityImpl.scala b/src/js/internal/LockCapabilityImpl.scala new file mode 100644 index 0000000..cde6b62 --- /dev/null +++ b/src/js/internal/LockCapabilityImpl.scala @@ -0,0 +1,43 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.concurrent.duration.FiniteDuration + +@scala.caps.assumeSafe +private[internal] final class LockCapabilityImpl( + scope: DaprCapabilityImpl, + val storeName: LockStoreName, +) extends LockCapability: + + def tryLock(resourceId: LockResourceId, lockOwner: LockOwner, expiry: FiniteDuration): Boolean = + // expiry.toSeconds truncates to whole seconds — the same rounding the JVM impl applies + // (`expiry.toSeconds.toInt` into LockRequest). + val response = JsAwait.await( + scope.client.lock.lock(storeName.value, resourceId.value, lockOwner.value, expiry.toSeconds.toDouble), + ) + // ScalablyTyped types `success` as a required Boolean; the equality test (rather than trusting the typed read) + // keeps the JVM twin's null-handling (`.toOption.exists(_.booleanValue())`): an absent/undefined field at + // runtime compares unequal to true and yields false instead of an undefined-as-Boolean read. + (response.success: Any) == true + + def unlock(resourceId: LockResourceId, lockOwner: LockOwner): UnlockStatus = + val response = JsAwait.await(scope.client.lock.unlock(storeName.value, resourceId.value, lockOwner.value)) + // Numeric LockStatus → UnlockStatus, copied from the JVM impl's UnlockResponseStatus mapping: + // Success (0) → Success, LockDoesNotExist (1) → LockNotFound, and LockBelongsToOthers (2) maps + // to InternalError exactly like the JVM maps LOCK_BELONG_TO_OTHERS (dapr4s's UnlockStatus has + // no owner-mismatch case); InternalError (3), unknown values, and an absent status (JVM: null + // response) all collapse to InternalError. + // + // The numeric values are PINNED here (`_statusToLockStatus`, implementation/Client/HTTPClient/ + // lock.js; enum LockStatus, types/lock/UnlockResponse.ts) instead of read off the SDK enum + // object like the other status mappings: LockStatus is not re-exported from the `@dapr/dapr` + // root, and its deep module (`@dapr/dapr/types/lock/UnlockResponse`) is unresolvable under + // Node ESM (ScalablyTyped emits extension-less specifiers; the package has no `exports` map — + // see the note in InvokeCapabilityImpl), so referencing the enum object would crash at module + // load. The type-tested read keeps unknown/absent shapes on the InternalError path. + // Every JS number pattern-matches as Double on Scala.js (see JsInterop.isFalsyJson). + (response.status: Any) match + case d: Double if d == 0 => UnlockStatus.Success + case d: Double if d == 1 => UnlockStatus.LockNotFound + case _ => UnlockStatus.InternalError diff --git a/src/js/internal/PublishCapabilityImpl.scala b/src/js/internal/PublishCapabilityImpl.scala new file mode 100644 index 0000000..b9cf3a4 --- /dev/null +++ b/src/js/internal/PublishCapabilityImpl.scala @@ -0,0 +1,155 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.scalajs.js +import scala.scalajs.js.JSConverters.* +import JsInterop.* +import dapr4styped.daprDapr.typesPubsubPubSubBulkPublishMessageDottypeMod.{ + PubSubBulkPublishMessage, + PubSubBulkPublishMessageExplicit, +} +import dapr4styped.daprDapr.typesPubsubPubSubPublishOptionsDottypeMod.PubSubPublishOptions +import dapr4styped.daprDapr.typesPubsubPubSubPublishResponseDottypeMod.PubSubPublishResponseType +import dapr4styped.node.globalsMod.global as NodeGlobals +import dapr4styped.undiciTypes.fetchMod.RequestInit + +@scala.caps.assumeSafe +private[internal] final class PublishCapabilityImpl( + scope: DaprCapabilityImpl, + val pubsubName: PubSubName, +) extends PublishCapability: + + import PublishCapabilityImpl.* + + // The pre-encoded JSON is parsed into a JS value and published with an explicit + // contentType = "application/json": with the header set, the SDK's serializeHttp + // (utils/Serializer.util.js) takes the JSON branch and JSON.stringify-s the value — yielding + // exactly our original document on the wire with the application/json content type, identical + // to the JVM impl (raw JSON bytes). WITHOUT the override the SDK would INFER the content type + // from the JS value (utils/Client.util.js getContentType), and a JSON scalar payload (string/ + // number/boolean) would be inferred as text/plain and sent via toString() — e.g. the JSON + // string "hi" would arrive as the 2 bytes `hi` instead of the 4 bytes `"hi"`. + // + // FALSY payloads cannot take the SDK path at all: HTTPClient.execute guards the request body + // with a JS truthiness check (`if (params?.body)` — implementation/Client/HTTPClient/ + // HTTPClient.js), so a payload parsing to 0, false, null or "" would be silently DROPPED and + // an empty body published. Those documents go through rawPublish (raw fetch) instead — see + // JsInterop.isFalsyJson. + def publish[T: JsonCodec](topic: Topic, data: T): Unit = + val json = summon[JsonCodec[T]].encode(data) + val parsed = parseJson(json) + if isFalsyJson(parsed) then rawPublish(scope.sidecar, pubsubName, topic, json, Map.empty) + else + val response = JsAwait.await( + scope.client.pubsub.publish(pubsubName.value, topic.value, asPublishData(parsed), jsonContentTypeOptions), + ) + throwIfFailed(response) + + def publishWithMetadata[T: JsonCodec]( + topic: Topic, + data: T, + metadata: Map[MetadataKey, MetadataValue], + ): Unit = + val json = summon[JsonCodec[T]].encode(data) + val parsed = parseJson(json) + // Falsy payloads bypass the SDK — see the publish comment above. + if isFalsyJson(parsed) then rawPublish(scope.sidecar, pubsubName, topic, json, metadata) + else + val options = PubSubPublishOptions().setContentType("application/json").setMetadata(toDict(metadata)) + val response = JsAwait.await( + scope.client.pubsub.publish(pubsubName.value, topic.value, asPublishData(parsed), options), + ) + throwIfFailed(response) + + def bulkPublish[T: JsonCodec](topic: Topic, entries: Seq[BulkPublishEntry[T]]): BulkPublishResult = + // The explicit {entryID, event, contentType} message shape keeps our entry IDs authoritative + // (the SDK generates random UUIDs otherwise — utils/Client.util.js getBulkPublishEntries; the + // explicit form is detected via `"event" in message`) and pins application/json per entry for + // the same scalar-payload reason as publish above. + // The explicit element-type ascription widens each entry to the SDK's PubSubBulkPublishMessage union + // (Explicit | js.Object | String) — js.Array is invariant, so an Array of the Explicit member type alone + // would not conform to the publishBulk parameter. + val messages = entries + .map[PubSubBulkPublishMessage] { entry => + PubSubBulkPublishMessageExplicit(asPublishData(parseJson(summon[JsonCodec[T]].encode(entry.event)))) + .setEntryID(entry.entryId.value) + .setContentType("application/json") + } + .toJSArray + val response = JsAwait.await(scope.client.pubsub.publishBulk(pubsubName.value, topic.value, messages)) + val failed = response.failedMessages.toList + // Whole-request failures must THROW (the JVM twin's bulkPublish throws DaprException there), + // but the SDK never lets them escape: publishBulk catches the rejection and fabricates a + // per-entry failure list covering every entry, all sharing the ONE caught error object + // (handleBulkPublishError/getBulkPublishResponse — implementation/Client/HTTPClient/pubsub.js + // + utils/Client.util.js). Genuine partial failures are built entry-by-entry with a fresh + // `new Error(entry.error)` each, so the SDK's whole-request fabrication is recognisable by + // this heuristic: every entry failed AND the error objects are reference-identical AND the + // error follows HTTPClient.execute's {error, error_msg, status} rejection convention + // (sdkFailureOf). Anything else stays a per-entry BulkPublishResult, mirroring the JVM. + failed.headOption match + case Some(first) + if failed.sizeIs == entries.size + && failed.forall(_.error eq first.error) + && sdkFailureOf(first.error).isDefined => + throw js.JavaScriptException(first.error) + case _ => + BulkPublishResult(failed.map(fm => BulkEntryId(fm.message.entryID))) + +@scala.caps.assumeSafe +private object PublishCapabilityImpl: + private val jsonContentTypeOptions = PubSubPublishOptions().setContentType("application/json") + + /** WHAT: asInstanceOf viewing a parsed JSON value (`js.Any`) as `js.Object`, the SDK's publish-data type. + * + * WHY: the TypeScript signature (`data?: object | string`) is narrower than the runtime contract — with the explicit + * application/json content type, `serializeHttp` JSON.stringify-s '''any''' JS value, and dapr4s must pass JSON + * scalar documents (truthy numbers/booleans/strings like `1.5`/`true`/`"hi"`) down this same path. (The String side + * of the TS union is not used: it would route scalar documents into the SDK's text/plain inference — see the + * `publish` comment.) Only JS-falsy values are excluded (they take the rawPublish bypass). + * + * WHY SAFE: erased, zero-cost view change; every value here came out of `JSON.parse`, and the SDK's only operation + * on it is the single `JSON.stringify` that puts our original document back on the wire. + */ + private def asPublishData(parsed: js.Any): js.Object = + parsed.asInstanceOf[js.Object] + + /** `pubsub.publish` soft-fails (`{error}` instead of rejecting — `implementation/Client/HTTPClient/pubsub.js`, typed + * `PubSubPublishResponseType` with an optional `error`); rethrow to mirror the JVM impl, where a failed + * `publishEvent` throws `DaprException`. + */ + private def throwIfFailed(response: PubSubPublishResponseType): Unit = + response.error.toOption.foreach(e => throw js.JavaScriptException(e)) + + /** Publish over the raw sidecar HTTP API (`POST /v1.0/publish/{pubsub}/{topic}`), bypassing the SDK. + * + * Exists because the SDK cannot transmit JS-falsy payloads: `HTTPClient.execute` only attaches a body when + * `if (params?.body)` is truthy (`node_modules/@dapr/dapr/implementation/Client/HTTPClient/HTTPClient.js`), so the + * JSON documents `0`, `false`, `null` and `""` would be published with an '''empty''' body. Same raw-fetch precedent + * as [[ActorCapabilityImpl]]/[[StateCapabilityImpl.getWithETag]]; the pre-encoded JSON string goes on the wire + * verbatim with `Content-Type: application/json` (no SDK serializer involved, so no double encoding), and metadata + * becomes `metadata.{key}` query parameters exactly like the SDK's `createHTTPQueryParam` builds them. Non-2xx + * answers throw with the shared raw-fetch message shape. + */ + private def rawPublish( + sidecar: SidecarConfig, + pubsubName: PubSubName, + topic: Topic, + json: String, + metadata: Map[MetadataKey, MetadataValue], + ): Unit = + import ActorCapabilityImpl.urlSegment + val query = + if metadata.isEmpty then "" + else + metadata + .map { case (k, v) => s"metadata.${urlSegment(k.value)}=${urlSegment(v.value)}" } + .mkString("?", "&", "") + val base = ActorCapabilityImpl.httpBase(sidecar) + val url = s"$base/v1.0/publish/${urlSegment(pubsubName.value)}/${urlSegment(topic.value)}$query" + val init = RequestInit().setMethod("POST").setHeaders(ActorCapabilityImpl.baseHeaders(sidecar)).setBody(json) + val response = JsAwait.await(NodeGlobals.fetch(url, init)) + // Always consume the body (Node fetch keep-alive invariant — see HttpActorContext.postJson). + val text = JsAwait.await(response.text()) + if response.status >= 400 then throw new RuntimeException(s"Dapr API error ${response.status} at $url: $text") diff --git a/src/js/internal/SecretsCapabilityImpl.scala b/src/js/internal/SecretsCapabilityImpl.scala new file mode 100644 index 0000000..03fd38b --- /dev/null +++ b/src/js/internal/SecretsCapabilityImpl.scala @@ -0,0 +1,81 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.scalajs.js +import JsInterop.* + +@scala.caps.assumeSafe +private[internal] final class SecretsCapabilityImpl( + scope: DaprCapabilityImpl, + val storeName: SecretStoreName, +) extends SecretsCapability: + + import SecretsCapabilityImpl.* + + def get(key: SecretKey, metadata: Map[MetadataKey, MetadataValue] = Map.empty): Option[SecretValue] = + val promise = metadataQuery(metadata) match + case None => scope.client.secret.get(storeName.value, key.value) + case Some(qs) => scope.client.secret.get(storeName.value, key.value, qs) + val response = JsAwait.await(promise) + // None-vs-throw semantics mirror the JVM impl exactly: a sidecar error (e.g. secret not found → 500/404) + // REJECTS the promise and propagates (the JVM's DaprException does too); None is returned only when the call + // succeeds but the {key: value} response object is empty or lacks the key. The single-entry fallback covers + // stores that answer under a different key name, same as the JVM's sizeIs == 1 branch. + val entries = toStringMap(response) + if entries.isEmpty then None + else + entries + .get(key.value) + .orElse(if entries.sizeIs == 1 then entries.valuesIterator.nextOption() else None) + .map(SecretValue(_)) + + def getBulk(metadata: Map[MetadataKey, MetadataValue] = Map.empty): Map[SecretKey, SecretValue] = + // The SDK's getBulk takes no metadata parameter (implementation/Client/HTTPClient/secret.js); the dapr4s + // metadata argument cannot be forwarded and is ignored, like the other knobs without a JS equivalent. The + // response is {secretName: {key: value}} — flattened to "secretName/key" compound keys exactly like the JVM. + val response = JsAwait.await(scope.client.secret.getBulk(storeName.value)) + if isAbsent(response) then Map.empty + else + val outer = response.asInstanceOf[js.Dictionary[js.Any]] + // WHAT: asInstanceOf views the parsed JSON response as a dictionary. + // WHY: the SDK types the response as `object`; the secrets bulk API contract is a JSON object of objects. + // WHY SAFE: js.Dictionary is a zero-cost view of any JS object (no runtime cast); inner values are + // re-checked via toStringMap before use. + outer.iterator.flatMap { case (secretKey, subMap) => + toStringMap(subMap).map { case (subKey, v) => SecretKey(s"$secretKey/$subKey") -> SecretValue(v) } + }.toMap + +@scala.caps.assumeSafe +private object SecretsCapabilityImpl: + + /** Render dapr4s metadata as the pre-built `metadata.k=v&...` query string `secret.get` expects (it is appended + * verbatim after `?` — `implementation/Client/HTTPClient/secret.js`). The `metadata.` prefix is the Dapr HTTP API + * convention the SDK's own `createHTTPQueryParam` uses elsewhere. + */ + private def metadataQuery(metadata: Map[MetadataKey, MetadataValue]): Option[String] = + if metadata.isEmpty then None + else + Some( + metadata.iterator + .map { case (k, v) => + s"metadata.${js.URIUtils.encodeURIComponent(k.value)}=${js.URIUtils.encodeURIComponent(v.value)}" + } + .mkString("&"), + ) + + /** View a `{key: value}` JS response object as a Scala string map, dropping non-string values. */ + private def toStringMap(v: js.Any): Map[String, String] = + if isAbsent(v) then Map.empty + else + val dict = v.asInstanceOf[js.Dictionary[js.Any]] + // WHAT: asInstanceOf views a parsed JSON value as a dictionary. + // WHY: the SDK types secret responses as `object`; property enumeration needs the dictionary view. + // WHY SAFE: js.Dictionary is a zero-cost view of any JS object; each value is pattern-matched for + // String below before being trusted, so a non-object/non-string payload degrades to an empty/smaller + // map rather than a ClassCastException. + dict.iterator.flatMap { case (k, value) => + (value: Any) match + case s: String => Some(k -> s) + case _ => None + }.toMap diff --git a/src/js/internal/StateCapabilityImpl.scala b/src/js/internal/StateCapabilityImpl.scala new file mode 100644 index 0000000..f6a81e6 --- /dev/null +++ b/src/js/internal/StateCapabilityImpl.scala @@ -0,0 +1,259 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.scalajs.js +import scala.scalajs.js.JSConverters.* +import JsInterop.* +import dapr4styped.daprDapr.anon.{PartialStateDeleteOptions, PartialStateGetOptions} +// The enum TYPES come from the deep modules (types are erased — no import is emitted), but the +// VALUES are read off the "@dapr/dapr" root re-exports: ScalablyTyped's deep-module specifiers +// carry no `.js` extension and `@dapr/dapr` has no `exports` map, so Node ESM (the Wasm/JSPI +// production target) cannot resolve them — see the same note in InvokeCapabilityImpl. +import dapr4styped.daprDapr.enumStateConcurrencyDotenumMod.StateConcurrencyEnum +import dapr4styped.daprDapr.enumStateConsistencyDotenumMod.StateConsistencyEnum +import dapr4styped.daprDapr.mod.{StateConcurrencyEnum as SdkConcurrency, StateConsistencyEnum as SdkConsistency} +import dapr4styped.daprDapr.typesKeyValuePairDottypeMod.KeyValuePairType +import dapr4styped.daprDapr.typesOperationDottypeMod.OperationType +import dapr4styped.daprDapr.typesRequestDottypeMod.IRequest +import dapr4styped.daprDapr.typesStateStateOptionsDottypeMod.IStateOptions +import dapr4styped.daprDapr.typesStateStateQueryDottypeMod.StateQueryType +import dapr4styped.daprDapr.typesStateStateSaveOptionsDottypeMod.StateSaveOptions +import dapr4styped.daprDapr.typesStateStateSaveResponseTypeMod.StateSaveResponseType +import dapr4styped.node.globalsMod.global as NodeGlobals +import dapr4styped.undiciTypes.fetchMod.RequestInit + +@scala.caps.assumeSafe +private object StateCapabilityImpl: + + /** dapr4s enum → the SDK's numeric `StateConsistencyEnum` (CONSISTENCY_EVENTUAL = 1, CONSISTENCY_STRONG = 2); + * `Default` maps to CONSISTENCY_UNSPECIFIED (0), which `getStateConsistencyValue` (`utils/Client.util.js`) turns + * into "no query parameter" exactly like `undefined` — the same store-default behaviour the JVM impl gets from + * passing a `null` Java enum. (The ScalablyTyped `IStateOptions` requires both fields, so the explicit UNSPECIFIED + * member replaces the hand facade's `js.undefined`; the wire behaviour is identical.) + */ + private def toJsConsistency(c: StateConsistency): StateConsistencyEnum = + c match + case StateConsistency.Default => SdkConsistency.CONSISTENCY_UNSPECIFIED + case StateConsistency.Eventual => SdkConsistency.CONSISTENCY_EVENTUAL + case StateConsistency.Strong => SdkConsistency.CONSISTENCY_STRONG + + /** dapr4s enum → numeric `StateConcurrencyEnum` (CONCURRENCY_FIRST_WRITE = 1, CONCURRENCY_LAST_WRITE = 2); `Default` + * → CONCURRENCY_UNSPECIFIED (0), same "no query parameter" mapping as [[toJsConsistency]]. + */ + private def toJsConcurrency(c: StateConcurrency): StateConcurrencyEnum = + c match + case StateConcurrency.Default => SdkConcurrency.CONCURRENCY_UNSPECIFIED + case StateConcurrency.FirstWrite => SdkConcurrency.CONCURRENCY_FIRST_WRITE + case StateConcurrency.LastWrite => SdkConcurrency.CONCURRENCY_LAST_WRITE + + /** The `?consistency=` query value of the raw state HTTP API, used by [[StateCapabilityImpl.getWithETag]]. */ + private def consistencyQuery(c: StateConsistency): Option[String] = + c match + case StateConsistency.Default => None + case StateConsistency.Eventual => Some("eventual") + case StateConsistency.Strong => Some("strong") + + private def decode[T: JsonCodec](raw: String | Null): T = + JsonCodec.decodeOrThrow[T](raw) + + /** WHAT: asInstanceOf putting a plain etag '''string''' where ScalablyTyped wants the SDK's `IEtag` object type + * (`{value: string}` per `types/Etag.type.ts`). + * + * WHY: the SDK's TS type is wrong against the wire contract. `state.transaction` sends the operations array verbatim + * (`body: {operations, metadata}` — `implementation/Client/HTTPClient/state.js`; no serializer touches the etag), + * and daprd's `POST /v1.0/state/{store}/transaction` unmarshals `request.etag` as a JSON '''string''' — an + * `{value: ...}` object would fail the request. The hand-written facade shipped the string form and was verified + * against a live sidecar; this preserves that wire format. + * + * WHY SAFE: erased, zero-cost; the value lands in the JSON body exactly as daprd's API reference + * (`TransactionalStateOperation.request.etag: string`) requires. + */ + private def toJsEtag(etag: ETag): dapr4styped.daprDapr.typesEtagDottypeMod.IEtag = + etag.value.asInstanceOf[dapr4styped.daprDapr.typesEtagDottypeMod.IEtag] + + private def toJsOp(op: StateOp): OperationType = + op match + case StateOp.UpsertOp(key, encodedValue, etag) => + val request = IRequest(key.value).setValue(parseJson(encodedValue.value)) + etag.foreach(e => request.setEtag(toJsEtag(e)): Unit) + OperationType(operation = "upsert", request = request) + case StateOp.DeleteOp(key, etag) => + val request = IRequest(key.value) + etag.foreach(e => request.setEtag(toJsEtag(e)): Unit) + OperationType(operation = "delete", request = request) + + /** ETag-conflict detection for conditional writes, the HTTP twin of the JVM impl's `isETagConflict(DaprException)` + * (`getHttpStatusCode == 409 || message.contains("ABORTED")`): the sidecar's HTTP API answers a mismatching + * `If-Match`/etag with '''409 Conflict''', and newer daprd versions additionally embed the + * `DAPR_STATE_ETAG_MISMATCH` error code in the body — both are accepted, mirroring the JVM's dual check (status code + * OR message marker) adapted from the gRPC to the HTTP transport. + */ + private def isETagConflict(f: SdkHttpFailure): Boolean = + f.status == 409 || f.errorMsg.contains("ETAG_MISMATCH") + + /** Map a `state.save`/`state.delete` soft failure: `None` on success, `Some` on ETag conflict, rethrow otherwise. + * + * The SDK does not reject these calls — it catches the error and returns `{error}` + * (`implementation/Client/HTTPClient/state.js`, typed `StateSaveResponseType` with an optional `error`); the JVM + * impl's `try/catch DaprException` becomes an inspection of that field here. Non-conflict errors are rethrown as + * `js.JavaScriptException` to mirror the JVM, where a non-conflict `DaprException` propagates. + */ + private def conflictOrThrow( + response: StateSaveResponseType, + key: StateStoreKey, + etag: ETag, + ): Option[ETagMismatchException] = + response.error.toOption match + case None => None + case Some(error) => + if sdkFailureOf(error).exists(isETagConflict) then Some(ETagMismatchException(key, etag)) + else throw js.JavaScriptException(error) + +@scala.caps.assumeSafe +private[internal] final class StateCapabilityImpl( + scope: DaprCapabilityImpl, + val storeName: StateStoreName, +) extends StateCapability: + + import StateCapabilityImpl.* + + def get[T: JsonCodec](key: StateStoreKey, consistency: StateConsistency = StateConsistency.Default): Option[T] = + val promise = + if consistency == StateConsistency.Default then scope.client.state.get(storeName.value, key.value) + else + scope.client.state.get( + storeName.value, + key.value, + PartialStateGetOptions().setConsistency(toJsConsistency(consistency)), + ) + // The result is typed `KeyValueType | String` by ScalablyTyped (the TS interface), but at runtime it is the + // parsed JSON body whatever its shape — asJsAny views the union as the js.Any it is. + val raw = JsAwait.await(promise) + // A missing key answers 204 with an empty body, which HTTPClient.execute's tryParseJson surfaces as the empty + // string — isAbsent maps that (and undefined/null) to None, mirroring the JVM's null/empty-value filter. + if isAbsent(raw) then None else Some(decode[T](js.JSON.stringify(asJsAny(raw)))) + + def getWithETag[T: JsonCodec]( + key: StateStoreKey, + consistency: StateConsistency = StateConsistency.Default, + ): StateEntry[T] = + // The SDK cannot express this operation: the sidecar returns the ETag of a single-key read in the `ETag` + // RESPONSE HEADER, but `HTTPClient.execute` returns only the parsed body and discards all response headers + // (implementation/Client/HTTPClient/HTTPClient.js), and `state.getBulk` (whose body does carry etags) cannot + // pass the consistency hint. So this method calls the raw state HTTP API with fetch and reads the header + // itself — the same SDK-bypass precedent as the JVM `HttpActorContext`. + import ActorCapabilityImpl.urlSegment + val query = consistencyQuery(consistency).fold("")(c => s"?consistency=$c") + val base = ActorCapabilityImpl.httpBase(scope.sidecar) + val url = s"$base/v1.0/state/${urlSegment(storeName.value)}/${urlSegment(key.value)}$query" + val response = JsAwait.await( + NodeGlobals.fetch(url, RequestInit().setMethod("GET").setHeaders(ActorCapabilityImpl.baseHeaders(scope.sidecar))), + ) + val body = JsAwait.await(response.text()) + if response.status == 204 then StateEntry[T](None, None) + else if response.status >= 400 then throw new RuntimeException(s"Dapr API error ${response.status} at $url: $body") + else + val etag = response.headers.get("etag") match + case null => None + case e => Some(ETag(e)) + val value = if body.isEmpty then None else Some(decode[T](body)) + StateEntry(value, etag) + + def getBulk[T: JsonCodec](keys: Seq[StateStoreKey]): Map[StateStoreKey, StateEntry[T]] = + if keys.isEmpty then Map.empty + else + // ScalablyTyped types the response items as `KeyValueType` (`{[key: string]: any}` in the TS interface), but + // the runtime shape — verified in implementation/Client/HTTPClient/state.js — is the raw sidecar response to + // `POST /v1.0/state/{store}/bulk`, passed through verbatim: `[{key, data, etag}]`, with `data` absent for + // missing keys. The fields are read through the dictionary view with type-tested values, so a shape change + // upstream degrades to absent entries instead of a ClassCastException. + val items = JsAwait.await(scope.client.state.getBulk(storeName.value, keys.map(_.value).toJSArray)) + items.map { item => + val key = item.get("key") match + case Some(s: String) => s + case _ => "" + val raw = item.get("data").filterNot(isAbsent) + val etag = item.get("etag") match + case Some(s: String) => Some(ETag(s)) + case _ => None + StateStoreKey(key) -> StateEntry(raw.map(d => decode[T](js.JSON.stringify(asJsAny(d)))), etag) + }.toMap + + def save[T: JsonCodec](key: StateStoreKey, value: T): Unit = + val json = summon[JsonCodec[T]].encode(value) + val entry = KeyValuePairType(key = key.value, value = parseJson(json)) + val response = JsAwait.await(scope.client.state.save(storeName.value, js.Array(entry))) + // `state.save` soft-fails ({error} instead of rejecting); rethrow to mirror the JVM, where saveState throws. + response.error.toOption.foreach(e => throw js.JavaScriptException(e)) + + def saveBulk[T: JsonCodec](entries: Seq[(StateStoreKey, T)]): Unit = + if entries.nonEmpty then + val jsEntries = entries.map { case (key, value) => + KeyValuePairType(key = key.value, value = parseJson(summon[JsonCodec[T]].encode(value))) + }.toJSArray + val response = JsAwait.await(scope.client.state.save(storeName.value, jsEntries)) + response.error.toOption.foreach(e => throw js.JavaScriptException(e)) + + def saveWithETag[T: JsonCodec]( + key: StateStoreKey, + value: T, + etag: ETag, + metadata: Map[MetadataKey, MetadataValue] = Map.empty, + consistency: StateConsistency = StateConsistency.Default, + concurrency: StateConcurrency = StateConcurrency.FirstWrite, + ): Option[ETagMismatchException] = + val json = summon[JsonCodec[T]].encode(value) + val entry = KeyValuePairType(key = key.value, value = parseJson(json)) + .setEtag(etag.value) + .setOptions( + IStateOptions( + concurrency = toJsConcurrency(concurrency), + consistency = toJsConsistency(consistency), + ), + ) + val options = StateSaveOptions().setMetadata(toDict(metadata)) + val response = JsAwait.await(scope.client.state.save(storeName.value, js.Array(entry), options)) + conflictOrThrow(response, key, etag) + + def delete(key: StateStoreKey): Unit = + val response = JsAwait.await(scope.client.state.delete(storeName.value, key.value)) + response.error.toOption.foreach(e => throw js.JavaScriptException(e)) + + def deleteWithETag( + key: StateStoreKey, + etag: ETag, + consistency: StateConsistency = StateConsistency.Default, + concurrency: StateConcurrency = StateConcurrency.FirstWrite, + ): Option[ETagMismatchException] = + val options = PartialStateDeleteOptions() + .setEtag(etag.value) + .setConsistency(toJsConsistency(consistency)) + .setConcurrency(toJsConcurrency(concurrency)) + val response = JsAwait.await(scope.client.state.delete(storeName.value, key.value, options)) + conflictOrThrow(response, key, etag) + + def transaction(ops: Seq[StateOp]): Unit = + JsAwait.await(scope.client.state.transaction(storeName.value, ops.map(toJsOp).toJSArray)): Unit + + def queryState[T: JsonCodec](query: StateQuery): List[StateEntry[T]] = + // WHAT: asInstanceOf[StateQueryType] on the parsed query document. + // WHY: dapr4s's StateQuery is the user's raw JSON query string (parse-don't-validate happens at the sidecar); + // ScalablyTyped wants the structured StateQueryType trait, but there is no checked conversion from a parsed + // JSON value to a structural trait — the cast IS the conversion (erased, zero-cost). + // WHY SAFE: the SDK never introspects the object beyond JSON.stringify-ing it onto the wire + // (implementation/Client/HTTPClient/state.js `query`), so any well-formed query document behaves identically + // to one built field-by-field; a malformed document is rejected by the sidecar exactly as on the JVM. + val jsQuery = parseJson(query.value).asInstanceOf[StateQueryType] + val response = JsAwait.await(scope.client.state.query(storeName.value, jsQuery)) + // `results` is required in the ST type but only conditionally present at runtime: the SDK substitutes + // `{results: []}` solely for an EMPTY response body (implementation/Client/HTTPClient/state.js `query`); + // a JSON body without a `results` field (e.g. `{"token": ...}` when the repeated field is empty) passes + // through verbatim. Guard like getBulk does, mirroring the JVM twin's getResults.toOption.fold(Nil). + val rawResults = response.results + val results = if js.isUndefined(rawResults) || (rawResults: Any) == null then List.empty else rawResults.toList + results.map { item => + val raw = Option(item.data).filterNot(isAbsent) + val etag = item.etag.toOption.map(ETag(_)) + StateEntry(raw.map(d => decode[T](js.JSON.stringify(asJsAny(d)))), etag) + } diff --git a/src/js/internal/WorkflowCapabilityImpl.scala b/src/js/internal/WorkflowCapabilityImpl.scala new file mode 100644 index 0000000..81114ad --- /dev/null +++ b/src/js/internal/WorkflowCapabilityImpl.scala @@ -0,0 +1,131 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.concurrent.duration.FiniteDuration +import scala.scalajs.js +import JsInterop.parseJson +// The status TYPE comes from the deep module (types are erased — no import is emitted), but the +// VALUES are read off the "@dapr/dapr" root re-export: ScalablyTyped's deep-module specifiers +// are unresolvable under Node ESM — see the note in InvokeCapabilityImpl. +import dapr4styped.daprDapr.mod.{DaprWorkflowClient, WorkflowRuntimeStatus as SdkStatuses} +import dapr4styped.daprDapr.workflowClientWorkflowStateMod.WorkflowState +import dapr4styped.daprDapr.workflowRuntimeWorkflowRuntimeStatusMod.WorkflowRuntimeStatus + +@scala.caps.assumeSafe +private[internal] final class WorkflowCapabilityImpl( + private val client: DaprWorkflowClient, +) extends WorkflowCapability: + + import WorkflowCapabilityImpl.* + + def start(name: WorkflowName): WorkflowInstanceId = + WorkflowInstanceId(JsAwait.await(client.scheduleNewWorkflow(name.value))) + + // Workflow inputs are passed as PARSED JS values: the vendored durabletask client JSON.stringify-s + // the input (client.js scheduleNewOrchestration), producing single-encoded JSON on the wire — + // the same as the JVM impl, which passes a parsed Jackson JsonNode for its serializer to encode once. + def start[I: JsonCodec](name: WorkflowName, input: I): WorkflowInstanceId = + val value = parseJson(summon[JsonCodec[I]].encode(input)) + WorkflowInstanceId(JsAwait.await(client.scheduleNewWorkflow(name.value, value))) + + def startWithId(name: WorkflowName, instanceId: WorkflowInstanceId): WorkflowInstanceId = + // The `input: Unit` overload passes `undefined` for the input slot, exactly like the hand + // facade's js.undefined — the vendored client then schedules the instance without an input. + WorkflowInstanceId(JsAwait.await(client.scheduleNewWorkflow(name.value, (), instanceId.value))) + + def startWithId[I: JsonCodec](name: WorkflowName, instanceId: WorkflowInstanceId, input: I): WorkflowInstanceId = + val value = parseJson(summon[JsonCodec[I]].encode(input)) + WorkflowInstanceId(JsAwait.await(client.scheduleNewWorkflow(name.value, value, instanceId.value))) + + def getStatus(instanceId: WorkflowInstanceId): Option[WorkflowSnapshot] = + val state = JsAwait.await(client.getWorkflowState(instanceId.value, true)) + // Unknown instances come back as `undefined` (the vendored newOrchestrationState returns + // nothing when the GetInstance response has exists = false) — unlike the Java SDK, which + // returns a state object with an empty name. The empty-name guard is kept anyway so both + // platforms agree should the JS SDK ever mirror the Java behaviour. + state.toOption.filter(_.name.nonEmpty).map(toSnapshot) + + def suspend(instanceId: WorkflowInstanceId): Unit = + JsAwait.await(client.suspendWorkflow(instanceId.value)): Unit + + def resume(instanceId: WorkflowInstanceId): Unit = + JsAwait.await(client.resumeWorkflow(instanceId.value)): Unit + + def terminate(instanceId: WorkflowInstanceId): Unit = + JsAwait.await(client.terminateWorkflow(instanceId.value, null)): Unit + + // Unlike workflow inputs, the event payload is passed as the RAW JSON STRING: the vendored client + // JSON.stringify-s it (client.js raiseOrchestrationEvent), producing a JSON string value whose + // content is our document — DOUBLE encoding, which is exactly what the JVM impl puts on the wire + // (it hands the encoded JSON String to the Java SDK, whose Jackson serializer encodes it again). + // The server-side waitForEvent decodes symmetrically, so the platforms stay wire-compatible. + def raiseEvent[E: JsonCodec](instanceId: WorkflowInstanceId, eventName: EventName, payload: E): Unit = + val jsonPayload: String = summon[JsonCodec[E]].encode(payload) + JsAwait.await(client.raiseEvent(instanceId.value, eventName.value, jsonPayload)): Unit + + def waitForCompletion(instanceId: WorkflowInstanceId, timeout: FiniteDuration): Option[WorkflowSnapshot] = + // On timeout, the vendored client REJECTS with its TimeoutError (a bare `class TimeoutError + // extends Error` constructed with message "TimeoutError" — workflow/internal/durabletask/ + // exception/timeout-error.js, raced against the gRPC call in waitForOrchestrationCompletion). + // The JVM impl lets the Java SDK's java.util.concurrent.TimeoutException propagate to the + // caller, so the rejection is translated to that exact exception type here — timeouts throw + // (never None); None is reserved for "instance not found", matching the JVM. + // The timeout is passed as fractional seconds (toMillis / 1000.0, NOT toSeconds, which would + // truncate sub-second timeouts to 0): the vendored client multiplies by 1000 for setTimeout, + // so millisecond precision survives — matching the JVM, which passes a full-precision Duration. + val state = + try JsAwait.await(client.waitForWorkflowCompletion(instanceId.value, true, timeout.toMillis.toDouble / 1000.0)) + catch + case e @ js.JavaScriptException(error: js.Error) => + if isVendoredTimeout(error) then + throw new java.util.concurrent.TimeoutException( + s"workflow '${instanceId.value}' did not complete within $timeout", + ) + else throw e + state.toOption.map(toSnapshot) + + def purge(instanceId: WorkflowInstanceId): Boolean = + JsAwait.await(client.purgeWorkflow(instanceId.value)) + +@scala.caps.assumeSafe +private object WorkflowCapabilityImpl: + + /** Recognise the vendored durabletask `TimeoutError`: it carries the literal message "TimeoutError" and its class + * name survives as `constructor.name` (the SDK ships unminified CommonJS, so the name is stable); checking either + * marker keeps detection robust. + */ + private def isVendoredTimeout(error: js.Error): Boolean = + // WHAT: asInstanceOf to js.Dynamic for untyped property access. + // WHY: `constructor.name` is not part of the js.Error facade. + // WHY SAFE: js.Dynamic is the untyped view of any JS value (no runtime cast); every JS object + // has a constructor with a (possibly empty) string name, and the result is only compared. + error.message == "TimeoutError" || + error.asInstanceOf[js.Dynamic].selectDynamic("constructor").selectDynamic("name").toString == "TimeoutError" + + private def toSnapshot(state: WorkflowState): WorkflowSnapshot = + WorkflowSnapshot( + name = WorkflowName(state.name), + instanceId = WorkflowInstanceId(state.instanceId), + status = toStatus(state.runtimeStatus), + createdAt = java.time.Instant.ofEpochMilli(state.createdAt.getTime().toLong), + lastUpdatedAt = java.time.Instant.ofEpochMilli(state.lastUpdatedAt.getTime().toLong), + serializedInput = state.serializedInput.toOption.map(SerializedJson(_)), + serializedOutput = state.serializedOutput.toOption.map(SerializedJson(_)), + ) + + /** Numeric `WorkflowRuntimeStatus` → [[WorkflowStatus]], mirroring the JVM impl's mapping. The JS enum has no + * CANCELED member (the JVM maps CANCELED → Canceled; a cancelled-status protobuf value would crash inside the JS + * SDK's own `fromOrchestrationStatus` before reaching us), and unknown values fall back to Pending exactly like the + * JVM maps a `null` status. The comparisons read the values off the real SDK enum object (ScalablyTyped imports them + * from the SDK module), so a renumbering upstream cannot silently corrupt the mapping. + */ + private def toStatus(rs: WorkflowRuntimeStatus): WorkflowStatus = + if rs == SdkStatuses.RUNNING then WorkflowStatus.Running + else if rs == SdkStatuses.COMPLETED then WorkflowStatus.Completed + else if rs == SdkStatuses.CONTINUED_AS_NEW then WorkflowStatus.ContinuedAsNew + else if rs == SdkStatuses.FAILED then WorkflowStatus.Failed + else if rs == SdkStatuses.TERMINATED then WorkflowStatus.Terminated + else if rs == SdkStatuses.PENDING then WorkflowStatus.Pending + else if rs == SdkStatuses.SUSPENDED then WorkflowStatus.Suspended + else WorkflowStatus.Pending diff --git a/src/js/internal/WorkflowContextImpl.scala b/src/js/internal/WorkflowContextImpl.scala new file mode 100644 index 0000000..d71b145 --- /dev/null +++ b/src/js/internal/WorkflowContextImpl.scala @@ -0,0 +1,242 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.concurrent.duration.FiniteDuration +import scala.scalajs.js +import unsafeExceptions.canThrowAny +import dapr4styped.daprDapr.workflowInternalDurabletaskTaskTaskMod.Task as SdkTask +import dapr4styped.daprDapr.workflowRuntimeWorkflowContextMod.WorkflowContext as SdkWorkflowContext +import dapr4styped.node.cryptoMod + +/** dapr4s [[dapr4s.Task]] over an SDK task + a pure decode step — the Scala.js twin of the JVM `TaskJson`/`TaskUnit` + * pair, unified because on JS every decode starts from the same `js.Any` the coroutine handshake delivers. + * + * The SDK task is held with a wildcard element type (`SdkTask[?]`): the orchestration executor only cares about the + * task '''object''' (its `instanceof Task` check and completion bookkeeping), while its element type varies by + * producer (`Task[Any]` from `callActivity`/`createTimer`, the composite `WhenAnyTask <: Task[Task[Any]]` from + * `whenAny`) — the decode step, not the task's type parameter, is what types the result. + * + * [[await]] hands the underlying SDK task to the orchestration executor through [[WorkflowCoroutine.exchange]] and + * decodes whatever value the executor feeds back. During replay the executor feeds already-completed results + * immediately (its `resume()` fast-loop), so `await()` returns without scheduling new work — the same replay-safety + * contract as the JVM. + * + * Platform notes versus the JVM `Task`: + * - a failed task surfaces from `await()` as `js.JavaScriptException(TaskFailedError)` (the JS SDK's failure type), + * where the JVM throws `io.dapr.durabletask.TaskFailedException` — both are `RuntimeException`s matched by + * `NonFatal`, but the concrete type is necessarily platform-specific (the Java SDK types do not exist on JS); + * - [[isCancelled]] is always `false`: the vendored JS task model has no cancellation state (no `isCancelled` + * member exists on the SDK `Task`), whereas the JVM SDK cancels e.g. timed-out external-event tasks. + */ +@scala.caps.assumeSafe +private[internal] final class TaskImpl[+O]( + private val sdkTask: SdkTask[?], + private val coroutine: WorkflowCoroutine, + // WHAT: the decode step stored capture-erased as AnyRef and cast back in await(). + // WHY: its honest type mentions the boxed type parameter O, so CC manufactures a fresh reach + // capability at every instantiation ("hiding {}") — a typed field would give each TaskImpl a + // non-empty capture set, breaking the empty-capture widening to Task[...]^{this} that the + // WorkflowContext methods rely on (same constraint the JVM TaskMap.map documents). + // WHY SAFE: the only writer is WorkflowContextImpl.mkTask, which takes a `js.Any => O` of the + // matching O, and the cast back in await() restores exactly that type; the function itself + // only closes over codecs/SDK task handles that live as long as the orchestration execution. + // WHERE TO LOOK: WorkflowActivityBridge.daprRef (JVM) — the canonical AnyRef-erasure pattern. + private val decodeRef: AnyRef, +) extends Task[O]: + def isDone: Boolean = sdkTask.isComplete + def isCancelled: Boolean = false + def await(): O = decodeRef.asInstanceOf[js.Any => O](coroutine.exchange(sdkTask)) + def map[U](f: O => U): Task[U]^{f} = new TaskMap(this, f) + +@scala.caps.assumeSafe +private[internal] final class TaskMap[O1, +O]( + private val task: Task[O1], + private val g: O1 => O, +) extends Task[O]: + def isDone: Boolean = task.isDone + def isCancelled: Boolean = task.isCancelled + def await(): O = g(task.await()) + // CC can't express that Task[O1] holds a TaskMap^{this.g}; cast this to Task[O] to pass it + // as the recursive task arg. Return type is still honest: callers see ^{this, f}. + def map[U](f: O => U): Task[U]^{this, f} = new TaskMap(this.asInstanceOf[Task[O]], f) + +/** Implements the dapr4s [[dapr4s.WorkflowContext]] over the SDK's public workflow context + the + * [[WorkflowCoroutine]] handshake — the Scala.js twin of the JVM `WorkflowContextImpl`, same contract method for + * method. (The SDK context/task types are the ScalablyTyped-generated ones, imported under `Sdk*` aliases because + * their natural names collide with the dapr4s public types `WorkflowContext`/`Task` that this very file must also + * reference.) + * + * ==Wire format (kept byte-identical to the JVM)== + * + * The JVM passes codec-encoded JSON '''strings''' through the Java SDK, whose Jackson serializer encodes them once + * more — so activity inputs/outputs and event payloads travel as JSON string literals wrapping the dapr4s document. + * The JS executor applies exactly one `JSON.stringify` on the way out (`runtime-orchestration-context.js` + * `callActivity`, `activity-executor.js` output) and one `JSON.parse` on the way in, so passing the codec-encoded + * string as the value reproduces the JVM wire format and round-trips through [[jsonOf]] on the receiving side. + * Passing the '''string''' (always truthy when non-empty, and codec output is never empty) also sidesteps the SDK's + * `input ? JSON.stringify(input) : undefined` falsy-input bug, which would silently drop inputs like `0` or `false` + * if parsed values were handed over. Workflow '''outputs''' are the exception, exactly as on the JVM (which passes a + * `JsonNode` to `complete` for the same reason): the parsed value is recorded so the executor's single + * `JSON.stringify` puts raw JSON on the wire. + */ +@scala.caps.assumeSafe +private[internal] final class WorkflowContextImpl( + private val ctx: SdkWorkflowContext, + private val coroutine: WorkflowCoroutine, + private val input: js.Any, +) extends WorkflowContext: + + import WorkflowContextImpl.* + + /** Per-execution `newUuid` counter — replay-deterministic because each replay re-runs the (deterministic) body from + * scratch with a fresh context, mirroring the per-executor counter in the Java SDK (`TaskOrchestrationExecutor`). + */ + private var uuidCounter: Int = 0 + + def instanceId: WorkflowInstanceId = + WorkflowInstanceId(ctx.getWorkflowInstanceId()) + + def isReplaying: Boolean = ctx.isReplaying() + + def getInput[I: JsonCodec]: Option[I] = + // The executor JSON.parses the raw input before calling the workflow fn (undefined when absent). Like the JVM's + // JsonNode handling, both client conventions are accepted: a JSON string literal (dapr4s clients and the Java + // SDK double-encode, see the class doc) decodes its content; any other parsed value (the HTTP workflow start + // API stores the body verbatim) is re-stringified and decoded as raw JSON. + if js.isUndefined(input) then None + else summon[JsonCodec[I]].decode(jsonOf(input)).toOption + + // Return types are annotated ^{this} to match the WorkflowContext trait: a Task captures the + // context so capture checking forbids it from outliving `run`. The TaskImpl instances are + // @assumeSafe (empty capture); widening empty -> ^{this} is sound, same as every sub-capability. + def callActivity[A](using d: ActivityDef[A])(input: d.Input): Task[d.Output]^{this} = + scheduleActivity(d.activityName, d.inputCodec.encode(input))(using d.outputCodec) + + def callActivity[A](using d: ActivityDef[A], ev: d.Input =:= Unit): Task[d.Output]^{this} = + scheduleActivity(d.activityName, "null")(using d.outputCodec) + + def callActivityByName[I: JsonCodec, O: JsonCodec](name: ActivityName, input: I): Task[O]^{this} = + scheduleActivity(name.value, summon[JsonCodec[I]].encode(input)) + + def callActivityByName[O: JsonCodec](name: ActivityName): Task[O]^{this} = + scheduleActivity(name.value, "null") + + private def scheduleActivity[O: JsonCodec](name: String, inputJson: String): TaskImpl[O] = + val sdkTask = ctx.callActivity(name, inputJson) + mkTask(sdkTask, decodeJson(summon[JsonCodec[O]], s"Failed to decode result of activity '$name'")) + + /** Construct a [[TaskImpl]], erasing the decode step's capture set into the AnyRef slot — see the `decodeRef` + * comment on [[TaskImpl]] for the WHAT/WHY/WHY-SAFE of the erasure. + */ + private def mkTask[O](sdkTask: SdkTask[?], decode: js.Any => O): TaskImpl[O] = + new TaskImpl(sdkTask, coroutine, decode.asInstanceOf[AnyRef]) + + def createTimer(duration: FiniteDuration): Task[Unit]^{this} = + // The SDK's numeric createTimer overload takes SECONDS (runtime-orchestration-context.js: fireAt * 1000 added to + // the deterministic current time); fractional seconds carry sub-second precision through the Date arithmetic. + val sdkTask = ctx.createTimer(seconds(duration)) + mkTask(sdkTask, _ => ()) + + def waitForExternalEvent[T: JsonCodec](name: EventName, timeout: FiniteDuration): Task[T]^{this} = + // The JS SDK has no timeout overload, so the JVM SDK's internal mechanism is reproduced explicitly: race the + // event against a durable timer (the Java SDK schedules the same internal timer) and fail the await when the + // timer wins. whenAny's result is the first-completed child Task object (when-any-task.js), compared by + // reference below. History ordering decides ties exactly like the JVM (the Java SDK's timer callback only + // cancels when the event task hasn't completed yet). One forced divergence: the JVM throws the Java SDK's + // io.dapr.durabletask.TaskCanceledException (and marks the task cancelled), which does not exist on JS — the + // timeout surfaces as java.util.concurrent.TimeoutException instead (the same type the workflow *client*'s + // waitForCompletion uses for timeouts on both platforms). + val eventTask = ctx.waitForExternalEvent(name.value) + val timerTask = ctx.createTimer(seconds(timeout)) + val anyTask = ctx.whenAny(js.Array(eventTask, timerTask)) + val decodeEvent = decodeJson(summon[JsonCodec[T]], s"Failed to decode external event '${name.value}'") + val eventName = name.value + mkTask[T]( + anyTask, + winner => + if winner eq timerTask then + throw new java.util.concurrent.TimeoutException( + s"external event '$eventName' was not raised within $timeout", + ) + else decodeEvent(JsInterop.asJsAny(eventTask.getResult())), + ) + + def waitForExternalEvent[T: JsonCodec](name: EventName): Task[T]^{this} = + val sdkTask = ctx.waitForExternalEvent(name.value) + mkTask(sdkTask, decodeJson(summon[JsonCodec[T]], s"Failed to decode external event '${name.value}'")) + + def complete[O: JsonCodec](output: O): Unit = + // Recorded on the coroutine and surfaced as the generator's {done: true, value} when run() returns; the executor + // then stores it via setComplete and stringifies once — raw JSON on the wire, like the JVM's complete(JsonNode). + // (Unlike the JVM SDK, completion is not committed until run() returns: scheduling further work after complete() + // is not flagged with the JVM's "orchestrator already complete" error, it is merely pointless.) + coroutine.recordOutput(js.JSON.parse(summon[JsonCodec[O]].encode(output))) + + def continueAsNew[I: JsonCodec](input: I): Unit = + // Encoded-string input for the same wire-format parity as activity inputs (the executor stringifies _newInput + // once — getActions in runtime-orchestration-context.js). saveEvents = true mirrors the JVM's single-argument + // continueAsNew, which delegates to continueAsNew(input, preserveUnprocessedEvents = true). + ctx.continueAsNew(summon[JsonCodec[I]].encode(input), saveEvents = true) + // The ContinueAsNewSignal thrown here must reach the fiber root in WorkflowCoroutine — do not catch it. + // (The JS SDK's continueAsNew only records state; the unwind that the Java SDK's ContinueAsNewInterruption + // provides is added here so code after continueAsNew never runs, the same contract as the JVM.) + throw new ContinueAsNewSignal + + def newUuid(): java.util.UUID = + // The JS SDK exposes no deterministic UUID, so the Java SDK's algorithm is mirrored + // (TaskOrchestrationExecutor.newUuid: RFC 4122 §4.3 name-based v5/SHA-1 over + // "--" in a fixed namespace). Determinism argument: instanceId is + // constant, currentUtcDateTime advances only via ORCHESTRATORSTARTED history timestamps (replayed identically), + // and the counter restarts at 0 for every (deterministic) re-execution — so the n-th newUuid() of an execution + // yields the same value on every replay. Cross-platform UUID equality with the JVM is NOT a goal (an instance + // always replays on the platform that hosts it); only replay-stability is, hence hashing the namespace UUID's + // string form instead of its raw bytes is fine. + val name = s"${ctx.getWorkflowInstanceId()}-${ctx.getCurrentUtcDateTime().toISOString()}-$uuidCounter" + uuidCounter += 1 + deterministicUuidV5(name) + +@scala.caps.assumeSafe +private[internal] object WorkflowContextImpl: + + /** FiniteDuration → the fractional seconds the SDK's numeric `createTimer` overload expects. */ + private def seconds(duration: FiniteDuration): Double = + duration.toMillis.toDouble / 1000.0 + + /** Recover the dapr4s JSON document from a value the executor `JSON.parse`d off the wire: a string '''is''' the + * document (the double-encoded convention shared with the JVM — see the [[WorkflowContextImpl]] doc), an absent + * value (`undefined` for empty activity results, or `null`) maps to `"null"` exactly like the JVM's + * `getInput(String.class)` null-handling, and any other parsed value (a foreign, single-encoded producer — e.g. an + * event raised via the raw HTTP API) is re-stringified. The JVM is stricter on that last case (Jackson fails to + * read a JSON object as `String`); accepting it here is a harmless superset. + */ + private[internal] def jsonOf(value: js.Any): String = + if js.isUndefined(value) || (value: Any) == null then "null" + else + (value: Any) match + case s: String => s + case _ => js.JSON.stringify(value) + + /** A pure decode step for [[TaskImpl]]: wire value → [[jsonOf]] → codec, failing like the JVM `TaskJson.await`. */ + private def decodeJson[O](codec: JsonCodec[O], error: String): js.Any => O = + raw => + codec.decode(jsonOf(raw)) match + case Right(v) => v + case Left(err) => throw RuntimeException(error, err) + + /** Fixed v5 namespace — the same one the Java SDK uses (`TaskOrchestrationExecutor.newUuid`). */ + private val UuidNamespace = "9e952958-5e33-4daf-827f-2fa12937b875" + + /** RFC 4122 §4.3 name-based UUID: SHA-1 over namespace + name (via Node's `crypto`, typed by the ScalablyTyped + * `@types/node` conversion, since the Scala.js javalib has no `MessageDigest`), truncated to 128 bits with the + * version (5) and variant bits patched in — the same bit surgery as the Java SDK's `UuidGenerator.generate`. + */ + private def deterministicUuidV5(name: String): java.util.UUID = + val hex = cryptoMod + .createHash("sha1") + .update(s"$UuidNamespace-$name", cryptoMod.Encoding.utf8) + .digest(cryptoMod.BinaryToTextEncoding.hex) + val msb = (java.lang.Long.parseUnsignedLong(hex.substring(0, 16), 16) & 0xffffffffffff0fffL) | (5L << 12) + val lsb = (java.lang.Long.parseUnsignedLong(hex.substring(16, 32), 16) & 0x3fffffffffffffffL) | + java.lang.Long.MIN_VALUE // the RFC variant bit pattern 10xx…: 0x8000000000000000 + java.util.UUID(msb, lsb) diff --git a/src/js/internal/WorkflowCoroutine.scala b/src/js/internal/WorkflowCoroutine.scala new file mode 100644 index 0000000..748f34d --- /dev/null +++ b/src/js/internal/WorkflowCoroutine.scala @@ -0,0 +1,336 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.scalajs.js +import scala.scalajs.js.annotation.JSName +import dapr4styped.daprDapr.workflowInternalDurabletaskTaskTaskMod.Task as SdkTask +import dapr4styped.daprDapr.workflowRuntimeWorkflowContextMod.WorkflowContext as SdkWorkflowContext + +/** The `{value, done}` object the AsyncGenerator protocol requires every `next()`/`throw()` step to resolve to (the + * orchestration executor destructures exactly these two properties — `runtime-orchestration-context.js` lines + * 113/135/162). + */ +private[internal] final class StepResult(val value: js.Any, val done: Boolean) extends js.Object + +/** Control-flow signal for [[dapr4s.WorkflowContext.continueAsNew]] on Scala.js — the platform twin of + * `io.dapr.durabletask.interruption.ContinueAsNewInterruption` on the JVM. + * + * The JS SDK's `continueAsNew` only records state on the orchestration context (`setContinuedAsNew`); it does not + * unwind the workflow body the way the Java SDK's interruption does. dapr4s therefore throws this signal itself so + * that user code after `continueAsNew` does not run — the same user-visible contract as the JVM. The fiber root in + * [[WorkflowCoroutine]] catches it (and nothing else) and treats it as normal completion; the SDK then emits only the + * CONTINUED_AS_NEW action because `setContinuedAsNew` already marked the context complete, making the executor's + * subsequent `setComplete` a no-op (`runtime-orchestration-context.js` `setComplete` returns early when + * `_isComplete`). + * + * Extends `ControlThrowable` so that a user's broad `catch case NonFatal(e)` cannot swallow it. (On the JVM the + * interruption is a `RuntimeException` that `NonFatal` *would* match — the documented contract is "never catch it" on + * both platforms; the JS encoding merely enforces it.) + */ +private[internal] final class ContinueAsNewSignal extends scala.util.control.ControlThrowable + +/** Coroutine bridge between a synchronous dapr4s [[dapr4s.Workflow]] body and the async-generator protocol the Dapr JS + * SDK's orchestration executor drives. + * + * ==What the executor actually does (vendored sources, read before touching this file)== + * + * `worker/orchestration-executor.js` (EXECUTIONSTARTED case): `const result = await fn(ctx, input)` — the registered + * function is expected to '''create''' a generator without running its body — then duck-types it via + * `typeof result?.[Symbol.asyncIterator] === "function"` and hands it to the runtime context. + * `worker/runtime-orchestration-context.js` then drives it strictly sequentially: + * - `run()`: `await generator.next()` once; `{done: true}` completes the orchestration with `value`, otherwise + * `value` becomes `_previousTask`. + * - `resume()` (called once per history event that completes a task): when `_previousTask.isFailed`, + * `await generator.throw(_previousTask._exception)`; when `_previousTask.isComplete`, + * `await generator.next(_previousTask._result)` in a loop that keeps feeding results while the newly yielded task + * is already complete (this loop is what makes replay work). Every yielded `value` must be + * `instanceof durabletask.Task` — so [[exchange]] hands over the '''SDK's own Task instances''', never wrappers. + * - `generator.return()` is '''never called''' (verified: no `.return(` call site in the executor or the runtime + * context), so [[`return`]] fails loudly instead of pretending to support cancellation semantics. + * - After the generator finishes, later events may still trigger `resume()` (the context never clears + * `_previousTask`), so a finished generator must answer post-completion `next()` with `{done: true}` — standard + * AsyncGenerator protocol, implemented in [[next]]. + * + * ==The fiber handshake== + * + * The workflow body runs inside its own `js.async { ... }` fiber (Wasm + JSPI). Two pairs of promise resolvers form + * the handshake: + * - ''step'' (`stepResolve`/`stepReject`): settles the promise the executor is currently `await`ing from + * `next()`/`throw()`. Resolved with `{value: sdkTask, done: false}` when the fiber yields ([[exchange]]), with + * `{value: output, done: true}` when the fiber returns, and rejected when the fiber throws (the executor then + * fails the orchestration via its `processEvent`/`execute` catch blocks → `ctx.setFailed`). + * - ''resume'' (`fiberResolve`/`fiberReject`): settles the promise the suspended fiber is orphan-`js.await`ing + * inside [[exchange]]. Resolved by `next(v)` with the task result, rejected by `throw(e)` with the task failure + * (which `js.await` rethrows into the workflow body as `js.JavaScriptException(e)` — the JS counterpart of the + * JVM's `TaskFailedException` from `Task.await()`). + * + * ==Why the unsynchronized handshake is safe (the load-bearing invariant)== + * + * The executor `await`s every `next()`/`throw()` before processing the next history event, so the generator side and + * the fiber strictly alternate — at any instant at most one of the two is runnable: between `next(v)` and the + * resulting yield/completion only the fiber runs (the executor is suspended on the step promise); between a yield and + * the following `next(v)` only the executor runs (the fiber is suspended on the resume promise). Combined with + * JavaScript's single-threaded execution (JSPI resumes a suspended Wasm stack as a promise reaction, never + * concurrently), each resolver field is written in one phase and consumed-and-cleared in the other, so the plain + * `var`s need no synchronization and the `IllegalStateException` branches below are genuinely unreachable under the + * vendored executor — they exist to fail loudly if a future SDK version ever drives the generator concurrently. + * + * ==Replay and the abandoned fiber== + * + * Each work item re-executes the orchestrator from scratch: a fresh generator (and so a fresh fiber) replays the full + * history. When history runs out at an incomplete task, the executor simply stops driving the generator and returns + * its accumulated actions; the fiber stays suspended on a resume promise that nobody will resolve and the whole + * coroutine graph becomes garbage once the executor drops it — abandoned JSPI stacks are collectable by design. This + * is the JS analogue of the JVM's `OrchestratorBlockedException`, which unwinds the orchestrator thread instead. One + * user-visible consequence of the differing mechanisms: a `try`/`finally` around a never-completing `Task.await()` + * runs its finalizer on every replay on the JVM (the unwind passes through it) but not on JS (the fiber is abandoned + * mid-suspension). Workflow code must be deterministic and effect-free outside activities anyway, so a finalizer with + * observable effects is already outside the contract on both platforms. + * + * ==Escape hatches== + * + * WHAT: the class is `@scala.caps.assumeSafe` and extends `js.Object` (a non-native JS class, so `next`/`throw`/ + * `return` and the `Symbol.asyncIterator` member are real JS properties the executor can call). + * + * WHY: capture checking cannot see through the promise handshake — the fiber closure captures `this` and the + * `Workflow`, and resolver functions flow through `js.Promise` constructors (JS interop types carry no capture + * annotations). + * + * WHY SAFE: every captured value lives exactly as long as the orchestration execution that owns it: the coroutine, its + * context, and the resolvers are all dropped together when the executor finishes the work item, and the alternation + * invariant above guarantees no value is used from two phases at once. See `DaprAppServer.erased` for the canonical + * JS-interop capture-erasure rationale. + */ +@scala.caps.assumeSafe +private[internal] final class WorkflowCoroutine( + private val workflow: Workflow, + private val sdkCtx: SdkWorkflowContext, + private val input: js.Any, +) extends js.Object: + + // ------------------------------------------------------------------------- + // Handshake state. Single-threaded; see the alternation invariant in the + // class doc for why plain unsynchronized `var`s are correct. + // ------------------------------------------------------------------------- + + /** The fiber has been started (first `next()` seen). */ + private var started: Boolean = false + + /** The fiber has returned or thrown; the generator answers `{done: true}` from now on. */ + private var finished: Boolean = false + + /** The workflow output recorded by `WorkflowContext.complete` (parsed JS value, `undefined` when never called). + * Becomes the generator's final `{done: true, value}` — which the executor stores via `setComplete` and + * `JSON.stringify`s once onto the wire, the same raw-JSON output convention as the JVM impl's + * `ctx.complete(JsonNode)`. + */ + private var output: js.Any = js.undefined + + /** Settles the executor's currently pending `next()`/`throw()` promise. Non-null exactly while the executor is + * suspended on a step. + */ + private var stepResolve: js.Function1[StepResult, Unit] | Null = null + private var stepReject: js.Function1[scala.Any, Unit] | Null = null + + /** Settles the resume promise the fiber is suspended on inside [[exchange]]. Non-null exactly while the fiber is + * suspended on a yield. + */ + private var fiberResolve: js.Function1[js.Any, Unit] | Null = null + private var fiberReject: js.Function1[scala.Any, Unit] | Null = null + + // ------------------------------------------------------------------------- + // Fiber side (called from the workflow body, inside the js.async fiber) + // ------------------------------------------------------------------------- + + /** `Task.await()`: hand `task` (one of the SDK's own Task instances — the executor `instanceof`-checks it) to the + * pending generator step, suspend this fiber on a fresh resume promise, and return whatever value the executor's + * next `next(v)` delivers — or rethrow (as `js.JavaScriptException`) whatever `throw(e)` injects. + * + * The resume promise is registered '''before''' the step is answered so that the executor's follow-up `next(v)` + * (even a hypothetical synchronous one) always finds the resolver in place. + */ + final private[internal] def exchange(task: SdkTask[?]): js.Any = + val resume = new js.Promise[js.Any]((resolve, reject) => { + fiberResolve = (v: js.Any) => { resolve(v); () } + fiberReject = (e: scala.Any) => { reject(e); () } + () + }) + answerStep(new StepResult(task, false)) + JsAwait.await(resume) + + /** Record the workflow output (`WorkflowContext.complete`); surfaced as the generator's completion value. */ + final private[internal] def recordOutput(v: js.Any): Unit = + output = v + + // ------------------------------------------------------------------------- + // Generator side (called by the orchestration executor, from JS frames) + // ------------------------------------------------------------------------- + + /** AsyncGenerator `next`. The first call starts the fiber (mirroring real generator semantics — the executor's + * comment at the `await fn(ctx, input)` call site relies on creation not executing the body); later calls resume the + * suspended fiber with `value`. Resolves with the fiber's next yield or its completion; called after completion, + * resolves `{value: undefined, done: true}` per the AsyncGenerator protocol (the executor does this when an event + * arrives for an already-finished orchestration — see the class doc). + */ + def next(value: js.Any = js.undefined): js.Promise[StepResult] = + if finished then js.Promise.resolve[StepResult](new StepResult(js.undefined, true)) + else + step { () => + if !started then + started = true + startFiber() + else resumeFiber(value) + } + + /** AsyncGenerator `throw` — how the executor delivers a failed task (`resume()` calls + * `generator.throw(task._exception)` with the `TaskFailedError`). Rejects the fiber's resume promise so the pending + * `Task.await()` rethrows it inside the workflow body; the body may catch it (the step then resolves with the next + * yield) or let it escape (the step rejects and the executor fails the orchestration). On a finished generator it + * rejects with `error`, matching the protocol. + */ + def `throw`(error: js.Any = js.undefined): js.Promise[StepResult] = + if finished then js.Promise.reject(error) + else if !started then + // The executor only throws into a generator that already yielded a (failed) task, so the fiber is necessarily + // started and suspended; anything else is an unsupported driving pattern — fail loudly, do not fake it. + js.Promise.reject( + new js.Error( + "dapr4s WorkflowCoroutine: generator.throw() before the first next() is not supported " + + "(the durabletask executor never does this — runtime-orchestration-context.js resume())", + ), + ) + else step(() => failFiber(error)) + + /** AsyncGenerator `return` — '''unsupported''', loudly: the vendored executor never calls it (no `.return(` call site + * in `orchestration-executor.js` or `runtime-orchestration-context.js`), and pretending to support cancellation + * would silently skip the workflow body's remaining code without the runtime semantics to back it. + */ + def `return`(value: js.Any = js.undefined): js.Promise[StepResult] = + js.Promise.reject( + new js.Error( + "dapr4s WorkflowCoroutine: generator.return() is not supported — the durabletask orchestration " + + "executor drives only next() and throw(); a call here means the driving contract changed upstream", + ), + ) + + /** The duck-typing hook: `orchestration-executor.js` detects a generator via + * `typeof result?.[Symbol.asyncIterator] === "function"` (and never actually invokes it). Returning `this` also + * satisfies the real AsyncIterable protocol for good measure. + */ + @JSName(js.Symbol.asyncIterator) + def asyncIterator(): WorkflowCoroutine = this + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** Register the step resolvers for one `next()`/`throw()` call, then run `drive` (start/resume/fail the fiber). A + * `drive` that throws (the unreachable-by-invariant branches) rejects the returned promise — the executor then fails + * the orchestration with the `IllegalStateException`, which is the loud failure we want. + */ + private def step(drive: () => Unit): js.Promise[StepResult] = + new js.Promise[StepResult]((resolve, reject) => { + if stepResolve != null || stepReject != null then + throw new IllegalStateException( + "dapr4s WorkflowCoroutine: a generator step is already pending — the durabletask executor is expected " + + "to await every next()/throw() before issuing the next one (see the sequential-driving invariant)", + ) + stepResolve = (r: StepResult) => { resolve(r); () } + stepReject = (e: scala.Any) => { reject(e); () } + drive() + () + }) + + /** Start the workflow body in its own `js.async` fiber. The body runs synchronously up to its first suspension (first + * `Task.await()`), which already answers the pending first step; completion/failure are routed to whichever step is + * pending at that time via promise reactions. + */ + private def startFiber(): Unit = + val completion: js.Promise[Unit] = js.async { + // ContinueAsNewSignal is dapr4s's own control-flow signal (see its doc): continueAsNew was already recorded on + // the SDK context, so the fiber just stops here — the executor's final setComplete is a no-op. This is a typed + // catch of our private signal, not a broad catch; everything else must escape and fail the orchestration. + try workflow.run(using new WorkflowContextImpl(sdkCtx, this, input)) + catch case _: ContinueAsNewSignal => () + } + // Route the fiber's terminal state to the pending step. The handlers never throw (answerStep/failStep fall back + // to console.error on invariant breach), so the derived promise cannot become an unhandled rejection. + val onDone: js.Function1[Unit, Unit] = _ => { + finished = true + answerStep(new StepResult(output, true)) + } + // A Scala exception escaping js.async rejects the promise with the Throwable itself (Scala.js Throwables are JS + // Errors), and js.JavaScriptException unwraps to the underlying JS error — so the executor's newFailureDetails + // sees the original TaskFailedError/Error, exactly like a native async generator's rejection. + val onFail: js.Function1[scala.Any, Unit] = err => { + finished = true + failStep(err) + } + completion.`then`[Unit](onDone, onFail): Unit + + /** Resolve the pending step with a yield or completion result. Called from the fiber ([[exchange]]) or from the + * completion reactions in [[startFiber]]; by the alternation invariant a step is always pending here. + */ + private def answerStep(result: StepResult): Unit = + val resolve = stepResolve + stepResolve = null + stepReject = null + resolve match + case null => + if result.done then + // Reachable only on invariant breach; nothing can receive the answer, so report loudly instead of + // throwing inside a promise reaction (which would surface as an unhandled rejection and kill Node). + js.Dynamic.global.console + .error( + "dapr4s WorkflowCoroutine: fiber completed with no pending generator step — invariant violated", + ): Unit + else + // From exchange(): throwing here propagates into the workflow body, fails the fiber, and ultimately fails + // the orchestration — the loudest available failure for a yield nobody is waiting for. + throw new IllegalStateException( + "dapr4s WorkflowCoroutine: Task.await() outside a pending generator step — the workflow body may only " + + "run between the executor's next()/throw() calls (sequential-driving invariant violated)", + ) + case r => r(result) + + /** Reject the pending step (fiber threw). Same invariant as [[answerStep]]; called only from a promise reaction, so + * the breach fallback logs instead of throwing. + */ + private def failStep(error: scala.Any): Unit = + val reject = stepReject + stepResolve = null + stepReject = null + reject match + case null => + js.Dynamic.global.console + .error( + s"dapr4s WorkflowCoroutine: fiber failed with no pending generator step — invariant violated: $error", + ): Unit + case r => r(error) + + /** Resume the suspended fiber with a task result (`next(v)`). */ + private def resumeFiber(value: js.Any): Unit = + val resolve = fiberResolve + fiberResolve = null + fiberReject = null + resolve match + case null => + throw new IllegalStateException( + "dapr4s WorkflowCoroutine: next(value) with no suspended fiber — the executor resumed a generator " + + "that never yielded (sequential-driving invariant violated)", + ) + case r => r(value) + + /** Reject the suspended fiber's resume promise with a task failure (`throw(e)`). */ + private def failFiber(error: scala.Any): Unit = + val reject = fiberReject + fiberResolve = null + fiberReject = null + reject match + case null => + throw new IllegalStateException( + "dapr4s WorkflowCoroutine: throw(error) with no suspended fiber — the executor threw into a generator " + + "that never yielded (sequential-driving invariant violated)", + ) + case r => r(error) diff --git a/src/js/internal/WorkflowHost.scala b/src/js/internal/WorkflowHost.scala new file mode 100644 index 0000000..22978ad --- /dev/null +++ b/src/js/internal/WorkflowHost.scala @@ -0,0 +1,151 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.scalajs.js +import dapr4styped.daprDapr.mod.WorkflowRuntime +import dapr4styped.daprDapr.typesWorkflowActivityDottypeMod.TWorkflowActivity +import dapr4styped.daprDapr.typesWorkflowInputOutputDottypeMod.{TInput, TOutput} +import dapr4styped.daprDapr.typesWorkflowWorkflowDottypeMod.TWorkflow + +/** Server-side workflow/activity hosting on Scala.js — the JS counterpart of the JVM `DaprAppServer`'s + * `WorkflowRuntimeBuilder` block, backed by the SDK's [[WorkflowRuntime]] and the [[WorkflowCoroutine]] bridge. + * + * ==WorkflowRuntime lifecycle facts (verified in the vendored durabletask sources)== + * + * `workflow/internal/durabletask/worker/task-hub-grpc-worker.js`: + * - `start()` is async but resolves as soon as the gRPC stub is created — the work-item stream runs detached in the + * background (`internalRunWorker` is deliberately not awaited) and connection errors after the first attempt are + * retried with backoff, so awaiting `start()` does not wait for (or guarantee) sidecar connectivity. + * - `stop()` is async and slow by design: it cancels the work-item stream, polls in-flight work items for up to 30s, + * closes the stub, then sleeps 1s (grpc-node shutdown quirk). It rejects if the worker is not running. + * - Registering with a duplicate name throws synchronously (`registry.js`: "A '' orchestrator already + * exists."). + * + * The registered workflow function (the SDK's `TWorkflow`) receives the '''public''' SDK `WorkflowContext` wrapper + * (WorkflowRuntime.js wraps the inner `RuntimeOrchestrationContext` before invoking the registered function) and the + * `JSON.parse`d workflow input (`undefined` when the instance was started without input). Its return value is + * `await`ed by the orchestration executor and then duck-typed: anything with a callable `[Symbol.asyncIterator]` + * property is driven as an async generator (`worker/orchestration-executor.js`, EXECUTIONSTARTED case) — which is + * exactly what [[WorkflowCoroutine]] hands back. (ScalablyTyped types the result as `Generator | TOutput` where + * `TOutput = Any`; the coroutine object rides the `Any` arm, and the executor's duck-typing — not the erased TS type — + * decides how it is driven.) + * + * The registered activity callback (`TWorkflowActivity`) receives the SDK's `WorkflowActivityContext` and the + * `JSON.parse`d activity input; a returned `js.Promise` is awaited by the activity executor + * (`worker/activity-executor.js` `isPromise` check) and the settled value is `JSON.stringify`ed once onto the wire. + * + * [[DaprAppServer.startAndBlock]] calls [[start]] exactly when `app.workflows` or `app.activities` is non-empty + * (mirroring the JVM's "created only if needed" condition) and closes the returned [[WorkflowHost.Handle]] during + * SIGINT/SIGTERM shutdown, after the HTTP server stops accepting connections (the JVM closes its `WorkflowRuntime` in + * the shutdown hook the same way). + */ +@scala.caps.assumeSafe +private[internal] object WorkflowHost: + + /** A running workflow runtime. [[close]] must be synchronous and non-suspending: it is invoked from a Node signal + * listener (a plain JavaScript frame), where JSPI suspension is impossible — see the shutdown path in + * [[DaprAppServer]]. + */ + trait Handle: + /** Stop the workflow runtime, draining in-flight orchestrations as the underlying SDK allows. */ + def close(): Unit + + /** Start hosting the given workflows and activities against the sidecar described by `sidecar`. + * + * Workflows register under their simple class names — the same `getSimpleName` rule as the JVM + * (`DaprAppServer`/`WorkflowBridge`), because the name appears in user-visible API URLs and `WorkflowName(...)` + * values. Activities register under their [[dapr4s.WorkflowActivity.activityName]]. One behavioural nuance versus + * the JVM: the JS SDK's registry throws on a duplicate name at registration time (the JVM silently keeps the first + * registration) — a loud failure for what is a bug either way. + * + * `runtime.start()` is awaited before returning to preserve the JVM ordering (`WorkflowRuntimeBuilder` → `rt.start` + * → HTTP server bind); per the vendored worker it resolves as soon as the gRPC stub exists — the work-item stream + * connects in the background with retries (see the class doc), so this does not block on sidecar availability, same + * as the JVM's non-blocking `start(false)`. + * + * @param workflows + * the [[dapr4s.Workflow]]s to register (under their simple class names, like the JVM) + * @param activities + * the [[dapr4s.WorkflowActivity]]s to register (under their `activityName`s) + * @param daprCapability + * the live capability scope activities run against (the JS analogue of the JVM `WorkflowActivityBridge`'s + * capability parameter) + * @param sidecar + * connection settings for the runtime's gRPC channel (endpoint, api token) + * @return + * a handle the server closes on shutdown + */ + def start( + workflows: List[Workflow], + activities: List[WorkflowActivity[?, ?]], + daprCapability: DaprCapability, + sidecar: SidecarConfig, + ): Handle = + val runtime = new WorkflowRuntime(DaprCapabilityImpl.workflowClientOptions(sidecar)) + + workflows.foreach { w => + // The TWorkflow function: create (NOT run) the coroutine generator for this execution. The executor awaits the + // returned value, duck-types it as an async generator via Symbol.asyncIterator, and drives it — see the + // WorkflowCoroutine doc for the full protocol. The closure captures only the @assumeSafe Workflow instance, so + // no capture-erasure cast is needed for the SAM conversion to the SDK's TWorkflow function type. + val fn: TWorkflow = (sdkCtx, input) => new WorkflowCoroutine(w, sdkCtx, JsInterop.asJsAny(input)) + runtime.registerWorkflowWithName(w.getClass.getSimpleName.nn, fn): Unit + } + + // WHAT: asInstanceOf[AnyRef] erasing the DaprCapability's capture set before it is captured by the activity + // callbacks below (and cast back at the use site in runActivity). + // WHY: a DaprCapability carries a non-empty CC capture set; capturing it directly in a closure handed to a JS + // facade method (whose js.Function2-based TWorkflowActivity type cannot carry capture annotations) is rejected + // by capture checking. + // WHY SAFE: the capability lives for the whole server lifetime (the enclosing Dapr.serve scope — startAndBlock + // never returns normally, and its only exceptional exit, a bind/server failure, stops this workflow runtime + // before unwinding out of that scope), so every activity invocation happens strictly within its lifetime. This + // is the exact same erasure the JVM twin performs for the same reason (WorkflowActivityBridge's daprRef: AnyRef + // parameter). + val daprRef: AnyRef = daprCapability.asInstanceOf[AnyRef] + + activities.foreach { a => + // The activity callback is invoked from a JS frame (the activity executor), so it must open its own js.async + // entry before touching dapr4s code — activities may suspend freely on capability calls (orphan js.await) + // inside it, the per-invocation analogue of the JVM's virtual-thread-per-activity. The returned js.Promise is + // awaited by the SDK (activity-executor.js isPromise check); a rejection becomes the activity's + // failureDetails → TASKFAILED → TaskFailedError inside the calling workflow, exactly like a JVM activity + // exception, so no catch is wanted here. + val fn: TWorkflowActivity[TInput, TOutput] = + (_, input) => js.async(runActivity(a, daprRef, JsInterop.asJsAny(input))) + runtime.registerActivityWithName(a.activityName, fn): Unit + } + + JsAwait.await(runtime.start()) + + new Handle: + private var closed = false + def close(): Unit = + // Fire-and-forget by contract: runtime.stop() is async (it drains in-flight work items for up to 30s — see + // the class doc) and close() runs in a signal-listener JS frame where suspension is impossible. The JVM + // hook can block on WorkflowRuntime.close(); here the drain continues in the background while + // DaprAppServer's bounded shutdown timer decides when the process exits. The rejection handler keeps a + // failing stop (or a double close racing the drain) from becoming an unhandled rejection, which would kill + // the process mid-shutdown. + if !closed then + closed = true + val onError: js.Function1[Any, Unit] = err => + js.Dynamic.global.console.warn(s"dapr4s: workflow runtime stop failed during shutdown: $err"): Unit + runtime.stop().`catch`[Unit](onError): Unit + + /** Decode the wire input, run the user activity with the (erasure-restored) capability, and return the encoded output + * — the JS twin of the JVM `WorkflowActivityBridge.run`, including the identical wire convention: the input arrives + * `JSON.parse`d (a JSON string under the dapr4s double-encoding convention, `undefined` when absent → `"null"`, both + * via [[WorkflowContextImpl.jsonOf]]) and the returned codec-encoded string is `JSON.stringify`ed once by the + * activity executor, reproducing the JVM's Jackson-serialized-String wire format. + */ + private def runActivity[I, O](activity: WorkflowActivity[I, O], daprRef: AnyRef, input: js.Any): js.Any = + val decoded = activity.inputCodec.decode(WorkflowContextImpl.jsonOf(input)) match + case Right(v) => v + case Left(err) => + throw RuntimeException(s"Failed to decode activity input for '${activity.getClass.getSimpleName}'", err) + // WHAT: asInstanceOf restoring the DaprCapability erased to AnyRef in start() above. + // WHY/WHY SAFE: see the daprRef comment in start() — same contract as the JVM WorkflowActivityBridge. + val output = activity.execute(decoded)(using daprRef.asInstanceOf[DaprCapability]) + activity.outputCodec.encode(output) diff --git a/src/js/internal/facade/ExpressModule.scala b/src/js/internal/facade/ExpressModule.scala new file mode 100644 index 0000000..f55a815 --- /dev/null +++ b/src/js/internal/facade/ExpressModule.scala @@ -0,0 +1,70 @@ +//> using target.platform "scala-js" +package dapr4s.internal.facade + +import scala.scalajs.js +import scala.scalajs.js.annotation.JSImport +import dapr4styped.expressServeStaticCore.mod.{Express, Handler} + +// --------------------------------------------------------------------------- +// The ONE hand-written facade that survives the ScalablyTyped migration. +// +// Everything else in the JS interop layer comes from the generated `dapr4styped.*` +// packages (see js-deps.scala). This file exists because ScalablyTyped cannot +// express two members of the express module object: +// +// 1. `dapr4styped.express.mod.apply()` calls through the module root captured as +// `@JSImport("express", JSImport.Namespace)`. express is a classic +// CommonJS module (`module.exports = createApplication`); under Node ES +// modules an `import * as ns` namespace object is NEVER callable, so the +// ST entry point throws `TypeError: ns is not a function` at runtime +// (verified in the ScalablyTyped spike under `jsModuleKind es`, the +// Wasm/JSPI production target). +// 2. `dapr4styped.express.mod.text` lost its type to a converter limitation — +// the generated member is `Any` with an inline `/* import warning: +// ResolveTypeQueries.resolve Loop while resolving typeof bodyParser.text +// */` — so the middleware factory cannot be invoked as typed. +// +// Both members live on the SAME runtime object: the CJS default export is the +// callable `createApplication` function which also carries the middleware +// factories (`text`, `json`, ...) as properties. A `JSImport.Default` binding +// yields exactly that object under both module kinds (under `commonjs` via +// Scala.js's `$moduleDefault` interop helper, under `es` via Node's CJS→ESM +// default interop), hence one native object with `apply` + `text`, typed with +// the ScalablyTyped-generated `Express`/`Handler` types so the rest of the +// code stays on the generated surface. Runtime-verified against a real +// sidecar by the e2e smoke run (see docs/DESIGN.md). +// --------------------------------------------------------------------------- + +/** The express module's CJS default export (`lib/express.js`): callable application factory with the middleware + * factories as properties. See the file header for why this cannot come from ScalablyTyped. + * + * The `apply` member denotes calling the imported value itself as a function (standard Scala.js facade rule for + * members named `apply` without `@JSName`). + */ +@js.native +@JSImport("express", JSImport.Default) +private[internal] object ExpressModule extends js.Object: + + /** `express()` — create an application (`lib/express.js` `createApplication`). */ + def apply(): Express = js.native + + /** `express.text(options)` — the re-exported body-parser text middleware (`exports.text = bodyParser.text`). With + * `type` set to the catch-all media range (star-slash-star) it captures every request body as a raw string in + * `req.body`, leaving JSON parsing to our dispatch code (mirroring the JVM server's raw `readBody`). Note + * body-parser's quirk: for requests it skips (no body, or no `Content-Type` to match), it sets `req.body = {}`, not + * a string — see `DaprAppServer.readBody`. + */ + def text(options: ExpressTextOptions): Handler = js.native + +/** Options for [[ExpressModule.text]] (body-parser `lib/types/text.js`). + * + * @param `type` + * the media type(s) to match (via type-is); the catch-all media range (star-slash-star) matches any present + * `Content-Type` + * @param limit + * maximum body size in bytes when passed as a number (`typeof opts.limit === 'number'` skips `bytes.parse`) + */ +private[internal] final class ExpressTextOptions( + val `type`: js.UndefOr[String] = js.undefined, + val limit: js.UndefOr[Double] = js.undefined, +) extends js.Object diff --git a/src/jvm/ConversationCapability.scala b/src/jvm/ConversationCapability.scala new file mode 100644 index 0000000..3de03d4 --- /dev/null +++ b/src/jvm/ConversationCapability.scala @@ -0,0 +1,44 @@ +//> using target.platform "jvm" +package dapr4s + +/** Capability for invoking a DAPR conversation (LLM) component. + * + * '''JVM-only:''' the Dapr JS SDK has no conversation API, so this capability (and [[DaprCapability.conversation]], + * via `DaprCapabilityPlatform`) exists only on the JVM — on Scala.js using it is a compile error. + * + * [[converse]] holds a multi-message exchange — message roles, optional tool/function calling, and usage reporting. + * Acquired via [[DaprCapability.conversation]]. + */ +@scala.caps.assumeSafe +trait ConversationCapability extends scala.caps.ExclusiveCapability: + val componentName: ConversationComponentName + + /** Hold a multi-message exchange with optional tool definitions. */ + def converse( + messages: Seq[ConversationMessage], + tools: Seq[ConversationTool] = Nil, + toolChoice: Option[ToolChoice] = None, + temperature: Option[Double] = None, + contextId: Option[ConversationContextId] = None, + scrubPii: Boolean = false, + ): ConversationResponse + +/** Companion-object API for [[ConversationCapability]]. + * + * Forwards to the `ConversationCapability` in the enclosing `using` context: + * {{{ + * def ask(prompt: String)(using ConversationCapability): ConversationResponse = + * ConversationCapability.converse(Seq(ConversationMessage.user(prompt))) + * }}} + */ +@scala.caps.assumeSafe +object ConversationCapability: + def converse( + messages: Seq[ConversationMessage], + tools: Seq[ConversationTool] = Nil, + toolChoice: Option[ToolChoice] = None, + temperature: Option[Double] = None, + contextId: Option[ConversationContextId] = None, + scrubPii: Boolean = false, + )(using cap: ConversationCapability): ConversationResponse = + cap.converse(messages, tools, toolChoice, temperature, contextId, scrubPii) diff --git a/src/jvm/ConversationModels.scala b/src/jvm/ConversationModels.scala new file mode 100644 index 0000000..ce6081d --- /dev/null +++ b/src/jvm/ConversationModels.scala @@ -0,0 +1,134 @@ +//> using target.platform "jvm" +package dapr4s + +// WHAT: no `import language.experimental.safe` here, although the file these models were split +// out of (src/shared/Models.scala) is in safe mode. +// WHY: with the safe import in this jvm-tagged file, the 3.10.0-RC1 nightly's capture checker +// fails on *unrelated* files — the `@scala.caps.assumeSafe` enums in src/shared/optypes +// (StateConcurrency, StateConsistency) error on their synthesized `values` method +// ("dapr4s.X.$values.clone(): fresh cannot flow into capture set {}"). Empirically bisected to +// exactly this file's safe import (see JobsModels.scala for the full account). +// WHY SAFE: safe mode only adds checking; these are pure data definitions (enums/case classes) +// with no capabilities, no escape hatches, and no side effects — there is nothing for safe mode +// to catch here. +// WHERE TO LOOK: src/jvm/JobsModels.scala (same workaround, full explanation); AGENTS.md +// "Escape hatches" section. + +// JVM-only: these models belong to the JVM-only ConversationCapability (the Dapr JS SDK has no +// conversation API — see DaprCapabilityPlatform). The conversation-related opaque types +// (ConversationComponentName, ConversationContextId, ModelName, ToolName, ToolCallId) stay +// shared in src/shared/optypes/. + +/** Role of a message in a [[ConversationCapability.converse]] exchange. */ +enum ConversationMessageRole: + case System, User, Assistant, Tool, Developer + +/** Why the model stopped generating a [[ConversationResultChoice]]. + * + * Providers report this as a free-form string; values outside the recognised set are preserved verbatim in + * [[FinishReason.Other]]. + */ +enum FinishReason: + case Stop + case Length + case ToolCalls + case ContentFilter + case Other(raw: String) + +object FinishReason: + /** Map a provider's raw finish-reason string onto a [[FinishReason]]; unknown values become [[Other]]. */ + def fromWire(raw: String): FinishReason = + raw.toLowerCase match + case "stop" => Stop + case "length" => Length + case "tool_calls" => ToolCalls + case "content_filter" => ContentFilter + case _ => Other(raw) + +/** Controls whether (and which) tool the model may call in a [[ConversationCapability.converse]] request. */ +enum ToolChoice: + /** Let the model decide whether to call a tool. */ + case Auto + + /** Forbid tool calls; the model must answer directly. */ + case None + + /** Require the model to call at least one tool. */ + case Required + + /** Require the model to call the named tool. */ + case Named(name: ToolName) + +object ToolChoice: + extension (tc: ToolChoice) + /** The string the Dapr conversation API expects for this choice. */ + def wireValue: String = tc match + case ToolChoice.Auto => "auto" + case ToolChoice.None => "none" + case ToolChoice.Required => "required" + case ToolChoice.Named(name) => name.value + +/** A single message in a [[ConversationCapability.converse]] request. + * + * Use the smart constructors ([[ConversationMessage.user]], [[ConversationMessage.system]], etc.) rather than the raw + * apply. + * + * @param role + * Who authored the message. + * @param text + * The message text. + * @param name + * Optional author name (used by some providers, e.g. to attribute a tool result). + */ +final case class ConversationMessage(role: ConversationMessageRole, text: String, name: Option[String] = None) +object ConversationMessage: + def system(text: String): ConversationMessage = ConversationMessage(ConversationMessageRole.System, text) + def user(text: String): ConversationMessage = ConversationMessage(ConversationMessageRole.User, text) + def assistant(text: String): ConversationMessage = ConversationMessage(ConversationMessageRole.Assistant, text) + def tool(text: String, name: Option[String] = None): ConversationMessage = + ConversationMessage(ConversationMessageRole.Tool, text, name) + def developer(text: String): ConversationMessage = ConversationMessage(ConversationMessageRole.Developer, text) + +/** A function/tool the model may call during a [[ConversationCapability.converse]] exchange. + * + * @param name + * The function name the model uses to invoke the tool. + * @param description + * Optional human-readable description that helps the model decide when to call it. + * @param parametersJson + * The function's parameter schema as a JSON object (typically a JSON Schema describing the arguments). + */ +final case class ConversationTool(name: ToolName, description: Option[String], parametersJson: SerializedJson) + +/** A tool/function call the model emitted in its response. */ +final case class ConversationToolCall(id: ToolCallId, functionName: ToolName, arguments: SerializedJson) + +/** The assistant message of a single [[ConversationResultChoice]]. */ +final case class ConversationResultMessage(content: String, toolCalls: List[ConversationToolCall]) + +/** One candidate completion within a [[ConversationResult]]. */ +final case class ConversationResultChoice( + finishReason: Option[FinishReason], + index: Long, + message: ConversationResultMessage, +) + +/** Token usage reported by the model for a [[ConversationResult]], when the provider supplies it. */ +final case class ConversationResultCompletionUsage( + promptTokens: Option[Long], + completionTokens: Option[Long], + totalTokens: Option[Long], +) + +/** One output of a [[ConversationResponse]] (one per conversation input). */ +final case class ConversationResult( + choices: List[ConversationResultChoice], + model: Option[ModelName], + usage: Option[ConversationResultCompletionUsage], +) + +/** The full response of a [[ConversationCapability.converse]] call. */ +final case class ConversationResponse( + contextId: Option[ConversationContextId], + outputs: List[ConversationResult], +) diff --git a/src/Dapr.scala b/src/jvm/Dapr.scala similarity index 97% rename from src/Dapr.scala rename to src/jvm/Dapr.scala index 094ca41..89ec13c 100644 --- a/src/Dapr.scala +++ b/src/jvm/Dapr.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s import scala.util.control.NonFatal @@ -42,9 +43,9 @@ class Dapr(config: DaprConfig = DaprConfig()): overrides.put(Properties.GRPC_ENDPOINT.getName, sc.grpcEndpoint.toString) overrides.put(Properties.GRPC_TLS_INSECURE.getName, sc.grpcTlsInsecure.toString) sc.apiToken.foreach(t => overrides.put(Properties.API_TOKEN.getName, t.value)) - sc.grpcTlsCertPath.foreach(p => overrides.put(Properties.GRPC_TLS_CERT_PATH.getName, p.toString)) - sc.grpcTlsKeyPath.foreach(p => overrides.put(Properties.GRPC_TLS_KEY_PATH.getName, p.toString)) - sc.grpcTlsCaPath.foreach(p => overrides.put(Properties.GRPC_TLS_CA_PATH.getName, p.toString)) + sc.grpcTlsCertPath.foreach(p => overrides.put(Properties.GRPC_TLS_CERT_PATH.getName, p.value)) + sc.grpcTlsKeyPath.foreach(p => overrides.put(Properties.GRPC_TLS_KEY_PATH.getName, p.value)) + sc.grpcTlsCaPath.foreach(p => overrides.put(Properties.GRPC_TLS_CA_PATH.getName, p.value)) new Properties(overrides) /** Acquire a `DaprClient`, run `body` with a `DaprCapability` in context, then release the client whether `body` @@ -102,9 +103,9 @@ class Dapr(config: DaprConfig = DaprConfig()): .withPropertyOverride(Properties.MAX_RETRIES, sc.maxRetries.toString) .withPropertyOverride(Properties.TIMEOUT, sc.timeout.toSeconds.toString) sc.apiToken.foreach(t => builder.withPropertyOverride(Properties.API_TOKEN, t.value)) - sc.grpcTlsCertPath.foreach(p => builder.withPropertyOverride(Properties.GRPC_TLS_CERT_PATH, p.toString)) - sc.grpcTlsKeyPath.foreach(p => builder.withPropertyOverride(Properties.GRPC_TLS_KEY_PATH, p.toString)) - sc.grpcTlsCaPath.foreach(p => builder.withPropertyOverride(Properties.GRPC_TLS_CA_PATH, p.toString)) + sc.grpcTlsCertPath.foreach(p => builder.withPropertyOverride(Properties.GRPC_TLS_CERT_PATH, p.value)) + sc.grpcTlsKeyPath.foreach(p => builder.withPropertyOverride(Properties.GRPC_TLS_KEY_PATH, p.value)) + sc.grpcTlsCaPath.foreach(p => builder.withPropertyOverride(Properties.GRPC_TLS_CA_PATH, p.value)) // AbstractDaprClient (the concrete type DaprClientBuilder.build() returns) implements both // DaprClient and DaprPreviewClient; clientPreview is the same instance viewed through the // preview API, so only `client` is closed below. diff --git a/src/jvm/DaprCapabilityPlatform.scala b/src/jvm/DaprCapabilityPlatform.scala new file mode 100644 index 0000000..9f53f29 --- /dev/null +++ b/src/jvm/DaprCapabilityPlatform.scala @@ -0,0 +1,50 @@ +//> using target.platform "jvm" +package dapr4s + +/** JVM half of the [[DaprCapability]] surface — the factory methods for building blocks the Dapr + * Java SDK supports but the Dapr JS SDK does not (jobs, conversation). + * + * [[DaprCapability]] extends this trait, so on the JVM these methods are ordinary members of + * `DaprCapability`. The Scala.js twin of this trait is empty, so on that platform the methods do + * not exist and using them is a compile error (see the platform-surface note on + * [[DaprCapability]]). + * + * WHY a self-type instead of `extends scala.caps.ExclusiveCapability`: the `^{this}` return + * types must refer to the same tracked capability instance as the rest of the `DaprCapability` + * factory methods. The self-type makes `this` have type `DaprCapability` (a tracked capability + * class), so `^{this}` here is checked identically to the `^{this}` annotations in the shared + * trait — sub-capabilities cannot outlive the root scope. + * + * WHY @assumeSafe: same reason as on [[DaprCapability]] itself — implementations live behind the + * `dapr4s.internal` SDK-interop wall and safe-mode user code consumes them through this trait. + */ +@scala.caps.assumeSafe +trait DaprCapabilityPlatform: + this: DaprCapability => + + /** Obtain the [[JobsCapability]] (shared; no named component). */ + def jobs: JobsCapability^{this} + + /** Obtain a [[ConversationCapability]] for the named conversation (LLM) component. */ + def conversation(componentName: ConversationComponentName): ConversationCapability^{this} + +/** JVM half of the [[DaprCapability$ DaprCapability companion]] transformer API — the + * transformer methods for the JVM-only building blocks (jobs, conversation), inherited by + * `object DaprCapability`. The Scala.js twin of this trait is empty. + * + * WHY @assumeSafe: identical to the shared companion — `cap.jobs` / `cap.conversation(...)` + * return capabilities carrying a `^{cap}` capture set, and passing them to a context function + * expecting the unannotated capability type widens the capture set. That is safe because the + * `^{this}` return types on [[DaprCapabilityPlatform]] prevent the sub-capabilities from + * outliving the root scope; see the full argument on the shared `DaprCapability` companion. + */ +@scala.caps.assumeSafe +trait DaprCapabilityCompanionPlatform: + + def jobs[T](body: JobsCapability ?=> T)(using cap: DaprCapability): T = + body(using cap.jobs.asInstanceOf[JobsCapability]) + + def conversation(componentName: ConversationComponentName)[T](body: ConversationCapability ?=> T)(using + cap: DaprCapability + ): T = + body(using cap.conversation(componentName).asInstanceOf[ConversationCapability]) diff --git a/src/jvm/JobsCapability.scala b/src/jvm/JobsCapability.scala new file mode 100644 index 0000000..83b00ad --- /dev/null +++ b/src/jvm/JobsCapability.scala @@ -0,0 +1,79 @@ +//> using target.platform "jvm" +package dapr4s + +/** Capability for scheduling DAPR jobs (client side). + * + * '''JVM-only:''' the Dapr JS SDK has no jobs API, so this capability (and [[DaprCapability.jobs]], via + * `DaprCapabilityPlatform`) exists only on the JVM — on Scala.js using it is a compile error. The inbound counterpart + * [[JobRoute]] '''is''' cross-platform: answering job triggers needs no SDK support, only an HTTP route. + * + * '''Dual:''' [[JobRoute]] is the inbound counterpart. Scheduling is decoupled from handling: a scheduled job fires as + * an inbound trigger the sidecar POSTs back to the app, handled by a `JobRoute` for the same [[JobName]] registered in + * the [[DaprApp]]. Acquired via [[DaprCapability.jobs]]. (Derivation binds the two through one trait: `Jobs.derive` ↔ + * `JobRoutes.deriveChecked`.) + */ +@scala.caps.assumeSafe +trait JobsCapability extends scala.caps.ExclusiveCapability: + + /** Schedule a recurring job. The `data` payload is delivered to the matching [[JobRoute]] each time the job fires. + * + * @param dueTime + * optional first-run time; if omitted the schedule determines the first run + * @param repeats + * optional cap on the number of times the job runs + * @param ttl + * optional expiry instant after which the job is removed + */ + def schedule[T: JsonCodec]( + name: JobName, + data: T, + schedule: JobSchedule, + dueTime: Option[java.time.Instant] = None, + repeats: Option[Int] = None, + ttl: Option[java.time.Instant] = None, + ): Unit + + /** Schedule a one-shot job that fires once at `dueTime`. */ + def scheduleOnce[T: JsonCodec]( + name: JobName, + data: T, + dueTime: java.time.Instant, + ttl: Option[java.time.Instant] = None, + ): Unit + + /** Fetch a job's stored definition. Returns `None` if no job with that name exists. */ + def get(name: JobName): Option[JobDetails] + + /** Delete a scheduled job (no-op if it does not exist). */ + def delete(name: JobName): Unit + +/** Companion-object API for [[JobsCapability]]. + * + * Forwards to the `JobsCapability` in the enclosing `using` context: + * {{{ + * def scheduleReminder(id: String)(using JobsCapability): Unit = + * JobsCapability.schedule(JobName(s"reminder-$id"), id, JobSchedule.Every(1.hour)) + * }}} + */ +@scala.caps.assumeSafe +object JobsCapability: + def schedule[T: JsonCodec]( + name: JobName, + data: T, + schedule: JobSchedule, + dueTime: Option[java.time.Instant] = None, + repeats: Option[Int] = None, + ttl: Option[java.time.Instant] = None, + )(using cap: JobsCapability): Unit = + cap.schedule(name, data, schedule, dueTime, repeats, ttl) + def scheduleOnce[T: JsonCodec]( + name: JobName, + data: T, + dueTime: java.time.Instant, + ttl: Option[java.time.Instant] = None, + )(using cap: JobsCapability): Unit = + cap.scheduleOnce(name, data, dueTime, ttl) + def get(name: JobName)(using cap: JobsCapability): Option[JobDetails] = + cap.get(name) + def delete(name: JobName)(using cap: JobsCapability): Unit = + cap.delete(name) diff --git a/src/jvm/JobsModels.scala b/src/jvm/JobsModels.scala new file mode 100644 index 0000000..4c78eb6 --- /dev/null +++ b/src/jvm/JobsModels.scala @@ -0,0 +1,63 @@ +//> using target.platform "jvm" +package dapr4s + +// WHAT: no `import language.experimental.safe` here, although the file these models were split +// out of (src/shared/Models.scala) is in safe mode. +// WHY: with the safe import in these jvm-tagged model files, the 3.10.0-RC1 nightly's capture +// checker fails on *unrelated* files — the `@scala.caps.assumeSafe` enums in src/shared/optypes +// (StateConcurrency, StateConsistency) error on their synthesized `values` method +// ("dapr4s.X.$values.clone(): fresh cannot flow into capture set {}"). Empirically bisected: the +// error appears/disappears deterministically with the safe import in ConversationModels.scala +// (and is order-fragile for this file), so both split-out model files stay out of safe mode. +// WHY SAFE: safe mode only adds checking; these are pure data definitions (enums/case classes) +// with no capabilities, no escape hatches, and no side effects — there is nothing for safe mode +// to catch here. +// WHERE TO LOOK: src/shared/Models.scala (the safe-mode original these were split from); +// AGENTS.md "Escape hatches" section. +import scala.concurrent.duration.FiniteDuration + +// JVM-only: these models belong to the JVM-only JobsCapability (the Dapr JS SDK has no jobs +// API — see DaprCapabilityPlatform). The job *trigger* side (JobRoute, JobName) stays shared. + +/** When a [[JobsCapability.schedule]] job should run. + * + * The Dapr scheduler accepts a cron expression, a fixed period, or one of the named shortcuts. Construct via the cases + * directly (e.g. `JobSchedule.Cron("0 30 * * * *")`, `JobSchedule.Every(5.seconds)`, `JobSchedule.Daily`). + */ +enum JobSchedule: + /** A standard cron expression (Dapr uses a 6-field, seconds-first format). */ + case Cron(expression: String) + + /** Run repeatedly with a fixed period between runs. */ + case Every(period: FiniteDuration) + + case Daily + case Hourly + case Weekly + case Monthly + case Yearly + +/** A job's stored definition, as returned by [[JobsCapability.get]]. + * + * @param name + * The job's [[JobName]]. + * @param data + * The job's payload as stored by the scheduler (the JSON the job was scheduled with), if any. + * @param scheduleExpression + * The raw schedule expression the scheduler holds (e.g. `"@every 5s"`, `"@daily"`, or a cron string), if the job is + * recurring. + * @param dueTime + * The one-shot due time, if the job was scheduled to run once at a specific instant. + * @param repeats + * The remaining number of times the job will run, if a repeat count was set. + * @param ttl + * The instant after which the job expires, if a TTL was set. + */ +final case class JobDetails( + name: JobName, + data: Option[SerializedJson], + scheduleExpression: Option[String], + dueTime: Option[java.time.Instant], + repeats: Option[Int], + ttl: Option[java.time.Instant], +) diff --git a/src/jvm/PemPathJvm.scala b/src/jvm/PemPathJvm.scala new file mode 100644 index 0000000..ffad9f6 --- /dev/null +++ b/src/jvm/PemPathJvm.scala @@ -0,0 +1,14 @@ +//> using target.platform "jvm" +package dapr4s + +import language.experimental.safe + +/** JVM-only [[PemPath]] interop: construct a [[PemPath]] directly from a `java.nio.file.Path` — `PemPath(path)` — so + * JVM callers are not forced through `PemPath(path.toString)` by hand. Lives in a jvm-tagged file because the + * cross-platform core must stay free of `java.nio.file` (absent on Scala.js). + * + * Declared as an extension method on the companion object: when no `PemPath.apply` overload matches the argument type, + * Scala 3 falls back to extension-method resolution on the receiver, so `PemPath(path)` call sites resolve here while + * the shared `PemPath(string)` overload keeps working on both platforms unchanged. + */ +extension (companion: PemPath.type) def apply(path: java.nio.file.Path): PemPath = PemPath(path.toString) diff --git a/src/jvm/derivation/ForwardersPlatform.scala b/src/jvm/derivation/ForwardersPlatform.scala new file mode 100644 index 0000000..152c7a6 --- /dev/null +++ b/src/jvm/derivation/ForwardersPlatform.scala @@ -0,0 +1,45 @@ +//> using target.platform "jvm" +package dapr4s.derivation + +import dapr4s.* +import java.time.Instant + +/** JVM half of [[Forwarders]] — the runtime forwarders for the JVM-only [[dapr4s.JobsCapability]] (the Dapr JS SDK has + * no jobs API; see `DaprCapabilityPlatform`). Inherited by `object Forwarders`, so code generated by the JVM-only + * `Jobs.derive` macro calls plain `Forwarders.jobSchedule`/`jobScheduleOnce`/`jobGet`. The Scala.js twin of this trait + * is empty. + * + * WHY @assumeSafe: same reason as on [[Forwarders]] itself — the forwarders call into `@assumeSafe` capability trait + * methods on behalf of generated code. + */ +@scala.caps.assumeSafe +trait ForwardersPlatform: + + // ---- Jobs ----------------------------------------------------------------- + + def jobSchedule[T]( + cap: JobsCapability, + name: JobName, + data: T, + schedule: JobSchedule, + dueTime: Option[Instant], + repeats: Option[Int], + ttl: Option[Instant], + codec: JsonCodec[T], + ): Unit = + given JsonCodec[T] = codec + cap.schedule[T](name, data, schedule, dueTime, repeats, ttl) + + def jobScheduleOnce[T]( + cap: JobsCapability, + name: JobName, + data: T, + dueTime: Instant, + ttl: Option[Instant], + codec: JsonCodec[T], + ): Unit = + given JsonCodec[T] = codec + cap.scheduleOnce[T](name, data, dueTime, ttl) + + def jobGet(cap: JobsCapability, name: JobName): Option[JobDetails] = + cap.get(name) diff --git a/src/derivation/Jobs.scala b/src/jvm/derivation/Jobs.scala similarity index 99% rename from src/derivation/Jobs.scala rename to src/jvm/derivation/Jobs.scala index 968dfb3..93e8227 100644 --- a/src/derivation/Jobs.scala +++ b/src/jvm/derivation/Jobs.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.derivation import dapr4s.* diff --git a/src/internal/ActorCapabilityImpl.scala b/src/jvm/internal/ActorCapabilityImpl.scala similarity index 98% rename from src/internal/ActorCapabilityImpl.scala rename to src/jvm/internal/ActorCapabilityImpl.scala index 4cf14a7..7fa0736 100644 --- a/src/internal/ActorCapabilityImpl.scala +++ b/src/jvm/internal/ActorCapabilityImpl.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* diff --git a/src/internal/BindingsCapabilityImpl.scala b/src/jvm/internal/BindingsCapabilityImpl.scala similarity index 98% rename from src/internal/BindingsCapabilityImpl.scala rename to src/jvm/internal/BindingsCapabilityImpl.scala index 3416d32..a00ddae 100644 --- a/src/internal/BindingsCapabilityImpl.scala +++ b/src/jvm/internal/BindingsCapabilityImpl.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* diff --git a/src/internal/ConfigurationCapabilityImpl.scala b/src/jvm/internal/ConfigurationCapabilityImpl.scala similarity index 98% rename from src/internal/ConfigurationCapabilityImpl.scala rename to src/jvm/internal/ConfigurationCapabilityImpl.scala index 3ec8ff1..3a20b8f 100644 --- a/src/internal/ConfigurationCapabilityImpl.scala +++ b/src/jvm/internal/ConfigurationCapabilityImpl.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* diff --git a/src/internal/ConversationCapabilityImpl.scala b/src/jvm/internal/ConversationCapabilityImpl.scala similarity index 99% rename from src/internal/ConversationCapabilityImpl.scala rename to src/jvm/internal/ConversationCapabilityImpl.scala index 202f150..8437ec0 100644 --- a/src/internal/ConversationCapabilityImpl.scala +++ b/src/jvm/internal/ConversationCapabilityImpl.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* diff --git a/src/internal/CryptoCapabilityImpl.scala b/src/jvm/internal/CryptoCapabilityImpl.scala similarity index 96% rename from src/internal/CryptoCapabilityImpl.scala rename to src/jvm/internal/CryptoCapabilityImpl.scala index ec50966..09662a8 100644 --- a/src/internal/CryptoCapabilityImpl.scala +++ b/src/jvm/internal/CryptoCapabilityImpl.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* diff --git a/src/internal/DaprAppServer.scala b/src/jvm/internal/DaprAppServer.scala similarity index 99% rename from src/internal/DaprAppServer.scala rename to src/jvm/internal/DaprAppServer.scala index aa1958d..3fa6dc5 100644 --- a/src/internal/DaprAppServer.scala +++ b/src/jvm/internal/DaprAppServer.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* diff --git a/src/internal/DaprCapabilityImpl.scala b/src/jvm/internal/DaprCapabilityImpl.scala similarity index 99% rename from src/internal/DaprCapabilityImpl.scala rename to src/jvm/internal/DaprCapabilityImpl.scala index a8cce60..322ef27 100644 --- a/src/internal/DaprCapabilityImpl.scala +++ b/src/jvm/internal/DaprCapabilityImpl.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* diff --git a/src/internal/FluxOps.scala b/src/jvm/internal/FluxOps.scala similarity index 96% rename from src/internal/FluxOps.scala rename to src/jvm/internal/FluxOps.scala index 792e755..f7b6162 100644 --- a/src/internal/FluxOps.scala +++ b/src/jvm/internal/FluxOps.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import scala.collection.immutable.ArraySeq diff --git a/src/internal/HttpActorContext.scala b/src/jvm/internal/HttpActorContext.scala similarity index 99% rename from src/internal/HttpActorContext.scala rename to src/jvm/internal/HttpActorContext.scala index 2099cde..9051b9c 100644 --- a/src/internal/HttpActorContext.scala +++ b/src/jvm/internal/HttpActorContext.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* diff --git a/src/internal/InvokeCapabilityImpl.scala b/src/jvm/internal/InvokeCapabilityImpl.scala similarity index 98% rename from src/internal/InvokeCapabilityImpl.scala rename to src/jvm/internal/InvokeCapabilityImpl.scala index 9158636..22f2161 100644 --- a/src/internal/InvokeCapabilityImpl.scala +++ b/src/jvm/internal/InvokeCapabilityImpl.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* diff --git a/src/internal/JobsCapabilityImpl.scala b/src/jvm/internal/JobsCapabilityImpl.scala similarity index 98% rename from src/internal/JobsCapabilityImpl.scala rename to src/jvm/internal/JobsCapabilityImpl.scala index e468977..c69a2f5 100644 --- a/src/internal/JobsCapabilityImpl.scala +++ b/src/jvm/internal/JobsCapabilityImpl.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* diff --git a/src/internal/Json.scala b/src/jvm/internal/Json.scala similarity index 95% rename from src/internal/Json.scala rename to src/jvm/internal/Json.scala index b584ea2..2edde5d 100644 --- a/src/internal/Json.scala +++ b/src/jvm/internal/Json.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import com.fasterxml.jackson.databind.ObjectMapper diff --git a/src/internal/LockCapabilityImpl.scala b/src/jvm/internal/LockCapabilityImpl.scala similarity index 97% rename from src/internal/LockCapabilityImpl.scala rename to src/jvm/internal/LockCapabilityImpl.scala index 24628fe..e401651 100644 --- a/src/internal/LockCapabilityImpl.scala +++ b/src/jvm/internal/LockCapabilityImpl.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* diff --git a/src/internal/MonoOps.scala b/src/jvm/internal/MonoOps.scala similarity index 99% rename from src/internal/MonoOps.scala rename to src/jvm/internal/MonoOps.scala index 2dcb9a3..dd2a1be 100644 --- a/src/internal/MonoOps.scala +++ b/src/jvm/internal/MonoOps.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal /** Reactor ↔ virtual-thread bridge. See [[MonoOps.awaitResult]]. */ diff --git a/src/internal/NullOps.scala b/src/jvm/internal/NullOps.scala similarity index 89% rename from src/internal/NullOps.scala rename to src/jvm/internal/NullOps.scala index a1e6fc4..445a692 100644 --- a/src/internal/NullOps.scala +++ b/src/jvm/internal/NullOps.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal /** Null-safety helpers for Java interop under Scala 3 explicit nulls. */ diff --git a/src/internal/PublishCapabilityImpl.scala b/src/jvm/internal/PublishCapabilityImpl.scala similarity index 98% rename from src/internal/PublishCapabilityImpl.scala rename to src/jvm/internal/PublishCapabilityImpl.scala index 893ff8d..d5ef199 100644 --- a/src/internal/PublishCapabilityImpl.scala +++ b/src/jvm/internal/PublishCapabilityImpl.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* diff --git a/src/internal/SecretsCapabilityImpl.scala b/src/jvm/internal/SecretsCapabilityImpl.scala similarity index 98% rename from src/internal/SecretsCapabilityImpl.scala rename to src/jvm/internal/SecretsCapabilityImpl.scala index 13423dc..743575f 100644 --- a/src/internal/SecretsCapabilityImpl.scala +++ b/src/jvm/internal/SecretsCapabilityImpl.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* diff --git a/src/internal/StateCapabilityImpl.scala b/src/jvm/internal/StateCapabilityImpl.scala similarity index 99% rename from src/internal/StateCapabilityImpl.scala rename to src/jvm/internal/StateCapabilityImpl.scala index edd556a..d323516 100644 --- a/src/internal/StateCapabilityImpl.scala +++ b/src/jvm/internal/StateCapabilityImpl.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* diff --git a/src/internal/WorkflowBridges.scala b/src/jvm/internal/WorkflowBridges.scala similarity index 99% rename from src/internal/WorkflowBridges.scala rename to src/jvm/internal/WorkflowBridges.scala index c2bc747..7fcee32 100644 --- a/src/internal/WorkflowBridges.scala +++ b/src/jvm/internal/WorkflowBridges.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* diff --git a/src/internal/WorkflowCapabilityImpl.scala b/src/jvm/internal/WorkflowCapabilityImpl.scala similarity index 99% rename from src/internal/WorkflowCapabilityImpl.scala rename to src/jvm/internal/WorkflowCapabilityImpl.scala index b449e20..19b4f14 100644 --- a/src/internal/WorkflowCapabilityImpl.scala +++ b/src/jvm/internal/WorkflowCapabilityImpl.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* diff --git a/src/internal/WorkflowContextImpl.scala b/src/jvm/internal/WorkflowContextImpl.scala similarity index 99% rename from src/internal/WorkflowContextImpl.scala rename to src/jvm/internal/WorkflowContextImpl.scala index 0292b63..5e1d636 100644 --- a/src/internal/WorkflowContextImpl.scala +++ b/src/jvm/internal/WorkflowContextImpl.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* diff --git a/src/Actors.scala b/src/shared/Actors.scala similarity index 100% rename from src/Actors.scala rename to src/shared/Actors.scala diff --git a/src/Capabilities.scala b/src/shared/Capabilities.scala similarity index 85% rename from src/Capabilities.scala rename to src/shared/Capabilities.scala index be6dba5..7a5bd28 100644 --- a/src/Capabilities.scala +++ b/src/shared/Capabilities.scala @@ -615,119 +615,3 @@ object CryptoCapability: /** Decrypt ciphertext into a UTF-8 string. */ def decryptString(ciphertext: ArraySeq[Byte])(using cap: CryptoCapability): String = cap.decryptString(ciphertext) - -// --------------------------------------------------------------------------- - -/** Capability for scheduling DAPR jobs (client side). - * - * '''Dual:''' [[JobRoute]] is the inbound counterpart. Scheduling is decoupled from handling: a scheduled job fires as - * an inbound trigger the sidecar POSTs back to the app, handled by a `JobRoute` for the same [[JobName]] registered in - * the [[DaprApp]]. Acquired via [[DaprCapability.jobs]]. (Derivation binds the two through one trait: `Jobs.derive` ↔ - * `JobRoutes.deriveChecked`.) - */ -@scala.caps.assumeSafe -trait JobsCapability extends scala.caps.ExclusiveCapability: - - /** Schedule a recurring job. The `data` payload is delivered to the matching [[JobRoute]] each time the job fires. - * - * @param dueTime - * optional first-run time; if omitted the schedule determines the first run - * @param repeats - * optional cap on the number of times the job runs - * @param ttl - * optional expiry instant after which the job is removed - */ - def schedule[T: JsonCodec]( - name: JobName, - data: T, - schedule: JobSchedule, - dueTime: Option[java.time.Instant] = None, - repeats: Option[Int] = None, - ttl: Option[java.time.Instant] = None, - ): Unit - - /** Schedule a one-shot job that fires once at `dueTime`. */ - def scheduleOnce[T: JsonCodec]( - name: JobName, - data: T, - dueTime: java.time.Instant, - ttl: Option[java.time.Instant] = None, - ): Unit - - /** Fetch a job's stored definition. Returns `None` if no job with that name exists. */ - def get(name: JobName): Option[JobDetails] - - /** Delete a scheduled job (no-op if it does not exist). */ - def delete(name: JobName): Unit - -/** Companion-object API for [[JobsCapability]]. - * - * Forwards to the `JobsCapability` in the enclosing `using` context: - * {{{ - * def scheduleReminder(id: String)(using JobsCapability): Unit = - * JobsCapability.schedule(JobName(s"reminder-$id"), id, JobSchedule.Every(1.hour)) - * }}} - */ -@scala.caps.assumeSafe -object JobsCapability: - def schedule[T: JsonCodec]( - name: JobName, - data: T, - schedule: JobSchedule, - dueTime: Option[java.time.Instant] = None, - repeats: Option[Int] = None, - ttl: Option[java.time.Instant] = None, - )(using cap: JobsCapability): Unit = - cap.schedule(name, data, schedule, dueTime, repeats, ttl) - def scheduleOnce[T: JsonCodec]( - name: JobName, - data: T, - dueTime: java.time.Instant, - ttl: Option[java.time.Instant] = None, - )(using cap: JobsCapability): Unit = - cap.scheduleOnce(name, data, dueTime, ttl) - def get(name: JobName)(using cap: JobsCapability): Option[JobDetails] = - cap.get(name) - def delete(name: JobName)(using cap: JobsCapability): Unit = - cap.delete(name) - -// --------------------------------------------------------------------------- - -/** Capability for invoking a DAPR conversation (LLM) component. - * - * [[converse]] holds a multi-message exchange — message roles, optional tool/function calling, and usage reporting. - * Acquired via [[DaprCapability.conversation]]. - */ -@scala.caps.assumeSafe -trait ConversationCapability extends scala.caps.ExclusiveCapability: - val componentName: ConversationComponentName - - /** Hold a multi-message exchange with optional tool definitions. */ - def converse( - messages: Seq[ConversationMessage], - tools: Seq[ConversationTool] = Nil, - toolChoice: Option[ToolChoice] = None, - temperature: Option[Double] = None, - contextId: Option[ConversationContextId] = None, - scrubPii: Boolean = false, - ): ConversationResponse - -/** Companion-object API for [[ConversationCapability]]. - * - * Forwards to the `ConversationCapability` in the enclosing `using` context: - * {{{ - * def ask(prompt: String)(using ConversationCapability): ConversationResponse = - * ConversationCapability.converse(Seq(ConversationMessage.user(prompt))) - * }}} - */ -@scala.caps.assumeSafe -object ConversationCapability: - def converse( - messages: Seq[ConversationMessage], - tools: Seq[ConversationTool] = Nil, - toolChoice: Option[ToolChoice] = None, - temperature: Option[Double] = None, - contextId: Option[ConversationContextId] = None, - scrubPii: Boolean = false, - )(using cap: ConversationCapability): ConversationResponse = - cap.converse(messages, tools, toolChoice, temperature, contextId, scrubPii) diff --git a/src/Charsets.scala b/src/shared/Charsets.scala similarity index 100% rename from src/Charsets.scala rename to src/shared/Charsets.scala diff --git a/src/DaprApp.scala b/src/shared/DaprApp.scala similarity index 100% rename from src/DaprApp.scala rename to src/shared/DaprApp.scala diff --git a/src/DaprCapability.scala b/src/shared/DaprCapability.scala similarity index 86% rename from src/DaprCapability.scala rename to src/shared/DaprCapability.scala index 602e07c..1fdbd02 100644 --- a/src/DaprCapability.scala +++ b/src/shared/DaprCapability.scala @@ -8,6 +8,17 @@ package dapr4s * capture checker enforces this via `^{this}` return type annotations on * the factory methods below. * + * '''Platform-specific capability surface.''' Not every Dapr building block exists + * in every Dapr SDK: the Dapr JS SDK has no jobs or conversation API. Rather than + * throwing `UnsupportedOperationException` at runtime, the platform-specific factory + * methods live in the inherited platform parent trait `DaprCapabilityPlatform` (and + * their companion transformer twins in `DaprCapabilityCompanionPlatform`) — parent + * traits, because the companion object must sit in the same file as the trait while + * the platform halves live in platform-tagged files. On the JVM the platform trait + * contributes `jobs` and `conversation`; on Scala.js both platform traits are empty. + * On a platform lacking a building block the method simply does not exist — using it + * is a compile error, not a runtime exception. + * * The factory methods can be called directly or via the companion-object * transformer API (see [[DaprCapability$ companion object]]): * @@ -32,7 +43,7 @@ package dapr4s * }}} */ @scala.caps.assumeSafe -trait DaprCapability extends scala.caps.ExclusiveCapability: +trait DaprCapability extends scala.caps.ExclusiveCapability, DaprCapabilityPlatform: /** Obtain a [[StateCapability]] for the named state store. */ def state(storeName: StateStoreName): StateCapability^{this} @@ -64,12 +75,6 @@ trait DaprCapability extends scala.caps.ExclusiveCapability: /** Obtain a [[CryptoCapability]] for the named crypto component. */ def crypto(componentName: CryptoComponentName): CryptoCapability^{this} - /** Obtain the [[JobsCapability]] (shared; no named component). */ - def jobs: JobsCapability^{this} - - /** Obtain a [[ConversationCapability]] for the named conversation (LLM) component. */ - def conversation(componentName: ConversationComponentName): ConversationCapability^{this} - /** Companion-object transformer API for [[DaprCapability]]. * @@ -106,7 +111,7 @@ trait DaprCapability extends scala.caps.ExclusiveCapability: * trait methods prevent sub-capabilities from outliving the root scope. */ @scala.caps.assumeSafe -object DaprCapability: +object DaprCapability extends DaprCapabilityCompanionPlatform: def state(storeName: StateStoreName)[T](body: StateCapability ?=> T)(using cap: DaprCapability): T = body(using cap.state(storeName).asInstanceOf[StateCapability]) @@ -137,11 +142,3 @@ object DaprCapability: def crypto(componentName: CryptoComponentName)[T](body: CryptoCapability ?=> T)(using cap: DaprCapability): T = body(using cap.crypto(componentName).asInstanceOf[CryptoCapability]) - - def jobs[T](body: JobsCapability ?=> T)(using cap: DaprCapability): T = - body(using cap.jobs.asInstanceOf[JobsCapability]) - - def conversation(componentName: ConversationComponentName)[T](body: ConversationCapability ?=> T)(using - cap: DaprCapability - ): T = - body(using cap.conversation(componentName).asInstanceOf[ConversationCapability]) diff --git a/src/DaprConfig.scala b/src/shared/DaprConfig.scala similarity index 93% rename from src/DaprConfig.scala rename to src/shared/DaprConfig.scala index ddcaf75..928ffa6 100644 --- a/src/DaprConfig.scala +++ b/src/shared/DaprConfig.scala @@ -58,6 +58,11 @@ case class DaprConfig( * Path to the client TLS private key file (PEM). Required when TLS is enabled. * @param grpcTlsCaPath * Path to the CA certificate file (PEM) for server verification. Required when TLS is enabled. + * + * Scala.js note: only `httpEndpoint`, `grpcEndpoint`, and `apiToken` are honoured by the JS backend, plus + * `grpcMaxInboundMessageSizeBytes`, which maps to the JS SDK's `maxBodySizeMb`; `timeout` and every other knob (the + * OkHttp/gRPC-Java transport settings, `maxRetries`) are silently ignored there, and the TLS material paths are + * currently JVM-only. * @param maxRetries * Number of times to retry failed SDK calls (default 0 = no retries). * @param timeout @@ -77,9 +82,9 @@ case class SidecarConfig( grpcKeepAliveTimeout: FiniteDuration = 5.seconds, grpcKeepAliveWithoutCalls: Boolean = true, grpcTlsInsecure: Boolean = false, - grpcTlsCertPath: Option[java.nio.file.Path] = None, - grpcTlsKeyPath: Option[java.nio.file.Path] = None, - grpcTlsCaPath: Option[java.nio.file.Path] = None, + grpcTlsCertPath: Option[PemPath] = None, + grpcTlsKeyPath: Option[PemPath] = None, + grpcTlsCaPath: Option[PemPath] = None, maxRetries: Int = 0, timeout: FiniteDuration = Duration.Zero, ) diff --git a/src/Exceptions.scala b/src/shared/Exceptions.scala similarity index 100% rename from src/Exceptions.scala rename to src/shared/Exceptions.scala diff --git a/src/JsonCodec.scala b/src/shared/JsonCodec.scala similarity index 100% rename from src/JsonCodec.scala rename to src/shared/JsonCodec.scala diff --git a/src/Models.scala b/src/shared/Models.scala similarity index 55% rename from src/Models.scala rename to src/shared/Models.scala index 70bf3db..19fba0c 100644 --- a/src/Models.scala +++ b/src/shared/Models.scala @@ -1,7 +1,6 @@ package dapr4s import language.experimental.safe -import scala.concurrent.duration.FiniteDuration /** Standard HTTP methods for service invocation requests. */ enum HttpMethod: @@ -203,168 +202,3 @@ final case class WorkflowSnapshot( serializedInput: Option[SerializedJson], serializedOutput: Option[SerializedJson], ) - -// --------------------------------------------------------------------------- -// Jobs -// --------------------------------------------------------------------------- - -/** When a [[JobsCapability.schedule]] job should run. - * - * The Dapr scheduler accepts a cron expression, a fixed period, or one of the named shortcuts. Construct via the cases - * directly (e.g. `JobSchedule.Cron("0 30 * * * *")`, `JobSchedule.Every(5.seconds)`, `JobSchedule.Daily`). - */ -enum JobSchedule: - /** A standard cron expression (Dapr uses a 6-field, seconds-first format). */ - case Cron(expression: String) - - /** Run repeatedly with a fixed period between runs. */ - case Every(period: FiniteDuration) - - case Daily - case Hourly - case Weekly - case Monthly - case Yearly - -/** A job's stored definition, as returned by [[JobsCapability.get]]. - * - * @param name - * The job's [[JobName]]. - * @param data - * The job's payload as stored by the scheduler (the JSON the job was scheduled with), if any. - * @param scheduleExpression - * The raw schedule expression the scheduler holds (e.g. `"@every 5s"`, `"@daily"`, or a cron string), if the job is - * recurring. - * @param dueTime - * The one-shot due time, if the job was scheduled to run once at a specific instant. - * @param repeats - * The remaining number of times the job will run, if a repeat count was set. - * @param ttl - * The instant after which the job expires, if a TTL was set. - */ -final case class JobDetails( - name: JobName, - data: Option[SerializedJson], - scheduleExpression: Option[String], - dueTime: Option[java.time.Instant], - repeats: Option[Int], - ttl: Option[java.time.Instant], -) - -// --------------------------------------------------------------------------- -// Conversation (LLM) -// --------------------------------------------------------------------------- - -/** Role of a message in a [[ConversationCapability.converse]] exchange. */ -enum ConversationMessageRole: - case System, User, Assistant, Tool, Developer - -/** Why the model stopped generating a [[ConversationResultChoice]]. - * - * Providers report this as a free-form string; values outside the recognised set are preserved verbatim in - * [[FinishReason.Other]]. - */ -enum FinishReason: - case Stop - case Length - case ToolCalls - case ContentFilter - case Other(raw: String) - -object FinishReason: - /** Map a provider's raw finish-reason string onto a [[FinishReason]]; unknown values become [[Other]]. */ - def fromWire(raw: String): FinishReason = - raw.toLowerCase match - case "stop" => Stop - case "length" => Length - case "tool_calls" => ToolCalls - case "content_filter" => ContentFilter - case _ => Other(raw) - -/** Controls whether (and which) tool the model may call in a [[ConversationCapability.converse]] request. */ -enum ToolChoice: - /** Let the model decide whether to call a tool. */ - case Auto - - /** Forbid tool calls; the model must answer directly. */ - case None - - /** Require the model to call at least one tool. */ - case Required - - /** Require the model to call the named tool. */ - case Named(name: ToolName) - -object ToolChoice: - extension (tc: ToolChoice) - /** The string the Dapr conversation API expects for this choice. */ - def wireValue: String = tc match - case ToolChoice.Auto => "auto" - case ToolChoice.None => "none" - case ToolChoice.Required => "required" - case ToolChoice.Named(name) => name.value - -/** A single message in a [[ConversationCapability.converse]] request. - * - * Use the smart constructors ([[ConversationMessage.user]], [[ConversationMessage.system]], etc.) rather than the raw - * apply. - * - * @param role - * Who authored the message. - * @param text - * The message text. - * @param name - * Optional author name (used by some providers, e.g. to attribute a tool result). - */ -final case class ConversationMessage(role: ConversationMessageRole, text: String, name: Option[String] = None) -object ConversationMessage: - def system(text: String): ConversationMessage = ConversationMessage(ConversationMessageRole.System, text) - def user(text: String): ConversationMessage = ConversationMessage(ConversationMessageRole.User, text) - def assistant(text: String): ConversationMessage = ConversationMessage(ConversationMessageRole.Assistant, text) - def tool(text: String, name: Option[String] = None): ConversationMessage = - ConversationMessage(ConversationMessageRole.Tool, text, name) - def developer(text: String): ConversationMessage = ConversationMessage(ConversationMessageRole.Developer, text) - -/** A function/tool the model may call during a [[ConversationCapability.converse]] exchange. - * - * @param name - * The function name the model uses to invoke the tool. - * @param description - * Optional human-readable description that helps the model decide when to call it. - * @param parametersJson - * The function's parameter schema as a JSON object (typically a JSON Schema describing the arguments). - */ -final case class ConversationTool(name: ToolName, description: Option[String], parametersJson: SerializedJson) - -/** A tool/function call the model emitted in its response. */ -final case class ConversationToolCall(id: ToolCallId, functionName: ToolName, arguments: SerializedJson) - -/** The assistant message of a single [[ConversationResultChoice]]. */ -final case class ConversationResultMessage(content: String, toolCalls: List[ConversationToolCall]) - -/** One candidate completion within a [[ConversationResult]]. */ -final case class ConversationResultChoice( - finishReason: Option[FinishReason], - index: Long, - message: ConversationResultMessage, -) - -/** Token usage reported by the model for a [[ConversationResult]], when the provider supplies it. */ -final case class ConversationResultCompletionUsage( - promptTokens: Option[Long], - completionTokens: Option[Long], - totalTokens: Option[Long], -) - -/** One output of a [[ConversationResponse]] (one per conversation input). */ -final case class ConversationResult( - choices: List[ConversationResultChoice], - model: Option[ModelName], - usage: Option[ConversationResultCompletionUsage], -) - -/** The full response of a [[ConversationCapability.converse]] call. */ -final case class ConversationResponse( - contextId: Option[ConversationContextId], - outputs: List[ConversationResult], -) diff --git a/src/Validation.scala b/src/shared/Validation.scala similarity index 100% rename from src/Validation.scala rename to src/shared/Validation.scala diff --git a/src/Workflows.scala b/src/shared/Workflows.scala similarity index 100% rename from src/Workflows.scala rename to src/shared/Workflows.scala diff --git a/src/derivation/Actor.scala b/src/shared/derivation/Actor.scala similarity index 100% rename from src/derivation/Actor.scala rename to src/shared/derivation/Actor.scala diff --git a/src/derivation/ActorDefinitions.scala b/src/shared/derivation/ActorDefinitions.scala similarity index 100% rename from src/derivation/ActorDefinitions.scala rename to src/shared/derivation/ActorDefinitions.scala diff --git a/src/derivation/ActorState.scala b/src/shared/derivation/ActorState.scala similarity index 100% rename from src/derivation/ActorState.scala rename to src/shared/derivation/ActorState.scala diff --git a/src/derivation/BindingRoutes.scala b/src/shared/derivation/BindingRoutes.scala similarity index 100% rename from src/derivation/BindingRoutes.scala rename to src/shared/derivation/BindingRoutes.scala diff --git a/src/derivation/Bindings.scala b/src/shared/derivation/Bindings.scala similarity index 100% rename from src/derivation/Bindings.scala rename to src/shared/derivation/Bindings.scala diff --git a/src/derivation/Configuration.scala b/src/shared/derivation/Configuration.scala similarity index 100% rename from src/derivation/Configuration.scala rename to src/shared/derivation/Configuration.scala diff --git a/src/derivation/Crypto.scala b/src/shared/derivation/Crypto.scala similarity index 100% rename from src/derivation/Crypto.scala rename to src/shared/derivation/Crypto.scala diff --git a/src/derivation/Forwarders.scala b/src/shared/derivation/Forwarders.scala similarity index 92% rename from src/derivation/Forwarders.scala rename to src/shared/derivation/Forwarders.scala index 2a2edb3..a4dfe99 100644 --- a/src/derivation/Forwarders.scala +++ b/src/shared/derivation/Forwarders.scala @@ -3,7 +3,6 @@ package dapr4s.derivation import dapr4s.* import scala.collection.immutable.ArraySeq import scala.concurrent.duration.FiniteDuration -import java.time.Instant /** Runtime forwarders for the capability `*.derive` macros (other than Invoke, * which has its own [[InvokeDerivationRuntime]]). @@ -13,9 +12,15 @@ import java.time.Instant * here — in ordinary Scala — keeps generated trees trivial: no synthesised `given`s (which the * compiler would lift and capture into the enclosing class) and no by-hand reconstruction of * the capabilities' interleaved type/`using` clauses. + * + * Forwarders for JVM-only capabilities (jobs: the Dapr JS SDK has no jobs API) live in the + * inherited platform parent trait `ForwardersPlatform` — empty on Scala.js, so generated code + * referencing e.g. `Forwarders.jobSchedule` resolves only on the JVM, where the `Jobs.derive` + * macro that generates such calls exists. (`Forwarders.jobRoute` stays here: the inbound + * job-trigger side is cross-platform.) */ @scala.caps.assumeSafe -object Forwarders: +object Forwarders extends ForwardersPlatform: // ---- Bindings ------------------------------------------------------------- @@ -135,35 +140,6 @@ object Forwarders: ): ArraySeq[Byte] = cap.encryptString(keyName, plaintext, algorithm) - // ---- Jobs ----------------------------------------------------------------- - - def jobSchedule[T]( - cap: JobsCapability, - name: JobName, - data: T, - schedule: JobSchedule, - dueTime: Option[Instant], - repeats: Option[Int], - ttl: Option[Instant], - codec: JsonCodec[T], - ): Unit = - given JsonCodec[T] = codec - cap.schedule[T](name, data, schedule, dueTime, repeats, ttl) - - def jobScheduleOnce[T]( - cap: JobsCapability, - name: JobName, - data: T, - dueTime: Instant, - ttl: Option[Instant], - codec: JsonCodec[T], - ): Unit = - given JsonCodec[T] = codec - cap.scheduleOnce[T](name, data, dueTime, ttl) - - def jobGet(cap: JobsCapability, name: JobName): Option[JobDetails] = - cap.get(name) - // ---- Workflow (client) ---------------------------------------------------- def wfStart(cap: WorkflowCapability, name: WorkflowName): WorkflowInstanceId = diff --git a/src/derivation/Invoke.scala b/src/shared/derivation/Invoke.scala similarity index 100% rename from src/derivation/Invoke.scala rename to src/shared/derivation/Invoke.scala diff --git a/src/derivation/InvokeDerivationRuntime.scala b/src/shared/derivation/InvokeDerivationRuntime.scala similarity index 100% rename from src/derivation/InvokeDerivationRuntime.scala rename to src/shared/derivation/InvokeDerivationRuntime.scala diff --git a/src/derivation/InvokeRoutes.scala b/src/shared/derivation/InvokeRoutes.scala similarity index 100% rename from src/derivation/InvokeRoutes.scala rename to src/shared/derivation/InvokeRoutes.scala diff --git a/src/derivation/JobRoutes.scala b/src/shared/derivation/JobRoutes.scala similarity index 100% rename from src/derivation/JobRoutes.scala rename to src/shared/derivation/JobRoutes.scala diff --git a/src/derivation/MacroSupport.scala b/src/shared/derivation/MacroSupport.scala similarity index 100% rename from src/derivation/MacroSupport.scala rename to src/shared/derivation/MacroSupport.scala diff --git a/src/derivation/Publish.scala b/src/shared/derivation/Publish.scala similarity index 100% rename from src/derivation/Publish.scala rename to src/shared/derivation/Publish.scala diff --git a/src/derivation/Secrets.scala b/src/shared/derivation/Secrets.scala similarity index 100% rename from src/derivation/Secrets.scala rename to src/shared/derivation/Secrets.scala diff --git a/src/derivation/State.scala b/src/shared/derivation/State.scala similarity index 100% rename from src/derivation/State.scala rename to src/shared/derivation/State.scala diff --git a/src/derivation/Subscriptions.scala b/src/shared/derivation/Subscriptions.scala similarity index 100% rename from src/derivation/Subscriptions.scala rename to src/shared/derivation/Subscriptions.scala diff --git a/src/derivation/Workflow.scala b/src/shared/derivation/Workflow.scala similarity index 100% rename from src/derivation/Workflow.scala rename to src/shared/derivation/Workflow.scala diff --git a/src/derivation/WorkflowActivities.scala b/src/shared/derivation/WorkflowActivities.scala similarity index 100% rename from src/derivation/WorkflowActivities.scala rename to src/shared/derivation/WorkflowActivities.scala diff --git a/src/derivation/WorkflowActivityCalls.scala b/src/shared/derivation/WorkflowActivityCalls.scala similarity index 100% rename from src/derivation/WorkflowActivityCalls.scala rename to src/shared/derivation/WorkflowActivityCalls.scala diff --git a/src/derivation/WorkflowEvents.scala b/src/shared/derivation/WorkflowEvents.scala similarity index 100% rename from src/derivation/WorkflowEvents.scala rename to src/shared/derivation/WorkflowEvents.scala diff --git a/src/derivation/annotations.scala b/src/shared/derivation/annotations.scala similarity index 100% rename from src/derivation/annotations.scala rename to src/shared/derivation/annotations.scala diff --git a/src/derivation/name.scala b/src/shared/derivation/name.scala similarity index 100% rename from src/derivation/name.scala rename to src/shared/derivation/name.scala diff --git a/src/optypes/ActivityName.scala b/src/shared/optypes/ActivityName.scala similarity index 100% rename from src/optypes/ActivityName.scala rename to src/shared/optypes/ActivityName.scala diff --git a/src/optypes/ActorId.scala b/src/shared/optypes/ActorId.scala similarity index 100% rename from src/optypes/ActorId.scala rename to src/shared/optypes/ActorId.scala diff --git a/src/optypes/ActorMethodName.scala b/src/shared/optypes/ActorMethodName.scala similarity index 100% rename from src/optypes/ActorMethodName.scala rename to src/shared/optypes/ActorMethodName.scala diff --git a/src/optypes/ActorStateKey.scala b/src/shared/optypes/ActorStateKey.scala similarity index 100% rename from src/optypes/ActorStateKey.scala rename to src/shared/optypes/ActorStateKey.scala diff --git a/src/optypes/ActorType.scala b/src/shared/optypes/ActorType.scala similarity index 100% rename from src/optypes/ActorType.scala rename to src/shared/optypes/ActorType.scala diff --git a/src/optypes/ApiToken.scala b/src/shared/optypes/ApiToken.scala similarity index 100% rename from src/optypes/ApiToken.scala rename to src/shared/optypes/ApiToken.scala diff --git a/src/optypes/AppId.scala b/src/shared/optypes/AppId.scala similarity index 100% rename from src/optypes/AppId.scala rename to src/shared/optypes/AppId.scala diff --git a/src/optypes/BindingName.scala b/src/shared/optypes/BindingName.scala similarity index 100% rename from src/optypes/BindingName.scala rename to src/shared/optypes/BindingName.scala diff --git a/src/optypes/BindingOperation.scala b/src/shared/optypes/BindingOperation.scala similarity index 100% rename from src/optypes/BindingOperation.scala rename to src/shared/optypes/BindingOperation.scala diff --git a/src/optypes/BulkEntryId.scala b/src/shared/optypes/BulkEntryId.scala similarity index 100% rename from src/optypes/BulkEntryId.scala rename to src/shared/optypes/BulkEntryId.scala diff --git a/src/optypes/CloudEventId.scala b/src/shared/optypes/CloudEventId.scala similarity index 100% rename from src/optypes/CloudEventId.scala rename to src/shared/optypes/CloudEventId.scala diff --git a/src/optypes/CloudEventSource.scala b/src/shared/optypes/CloudEventSource.scala similarity index 100% rename from src/optypes/CloudEventSource.scala rename to src/shared/optypes/CloudEventSource.scala diff --git a/src/optypes/CloudEventSpecVersion.scala b/src/shared/optypes/CloudEventSpecVersion.scala similarity index 100% rename from src/optypes/CloudEventSpecVersion.scala rename to src/shared/optypes/CloudEventSpecVersion.scala diff --git a/src/optypes/CloudEventType.scala b/src/shared/optypes/CloudEventType.scala similarity index 100% rename from src/optypes/CloudEventType.scala rename to src/shared/optypes/CloudEventType.scala diff --git a/src/optypes/ConfigurationKey.scala b/src/shared/optypes/ConfigurationKey.scala similarity index 100% rename from src/optypes/ConfigurationKey.scala rename to src/shared/optypes/ConfigurationKey.scala diff --git a/src/optypes/ConfigurationStoreName.scala b/src/shared/optypes/ConfigurationStoreName.scala similarity index 100% rename from src/optypes/ConfigurationStoreName.scala rename to src/shared/optypes/ConfigurationStoreName.scala diff --git a/src/optypes/ConfigurationValue.scala b/src/shared/optypes/ConfigurationValue.scala similarity index 100% rename from src/optypes/ConfigurationValue.scala rename to src/shared/optypes/ConfigurationValue.scala diff --git a/src/optypes/ConfigurationVersion.scala b/src/shared/optypes/ConfigurationVersion.scala similarity index 100% rename from src/optypes/ConfigurationVersion.scala rename to src/shared/optypes/ConfigurationVersion.scala diff --git a/src/optypes/ContentType.scala b/src/shared/optypes/ContentType.scala similarity index 100% rename from src/optypes/ContentType.scala rename to src/shared/optypes/ContentType.scala diff --git a/src/optypes/ConversationComponentName.scala b/src/shared/optypes/ConversationComponentName.scala similarity index 100% rename from src/optypes/ConversationComponentName.scala rename to src/shared/optypes/ConversationComponentName.scala diff --git a/src/optypes/ConversationContextId.scala b/src/shared/optypes/ConversationContextId.scala similarity index 100% rename from src/optypes/ConversationContextId.scala rename to src/shared/optypes/ConversationContextId.scala diff --git a/src/optypes/CryptoComponentName.scala b/src/shared/optypes/CryptoComponentName.scala similarity index 100% rename from src/optypes/CryptoComponentName.scala rename to src/shared/optypes/CryptoComponentName.scala diff --git a/src/optypes/CryptoKeyName.scala b/src/shared/optypes/CryptoKeyName.scala similarity index 100% rename from src/optypes/CryptoKeyName.scala rename to src/shared/optypes/CryptoKeyName.scala diff --git a/src/optypes/DaprDuration.scala b/src/shared/optypes/DaprDuration.scala similarity index 100% rename from src/optypes/DaprDuration.scala rename to src/shared/optypes/DaprDuration.scala diff --git a/src/optypes/DaprPort.scala b/src/shared/optypes/DaprPort.scala similarity index 100% rename from src/optypes/DaprPort.scala rename to src/shared/optypes/DaprPort.scala diff --git a/src/optypes/ETag.scala b/src/shared/optypes/ETag.scala similarity index 100% rename from src/optypes/ETag.scala rename to src/shared/optypes/ETag.scala diff --git a/src/optypes/EventName.scala b/src/shared/optypes/EventName.scala similarity index 100% rename from src/optypes/EventName.scala rename to src/shared/optypes/EventName.scala diff --git a/src/optypes/InvokeMethodName.scala b/src/shared/optypes/InvokeMethodName.scala similarity index 100% rename from src/optypes/InvokeMethodName.scala rename to src/shared/optypes/InvokeMethodName.scala diff --git a/src/optypes/JobName.scala b/src/shared/optypes/JobName.scala similarity index 100% rename from src/optypes/JobName.scala rename to src/shared/optypes/JobName.scala diff --git a/src/optypes/KeyWrapAlgorithm.scala b/src/shared/optypes/KeyWrapAlgorithm.scala similarity index 100% rename from src/optypes/KeyWrapAlgorithm.scala rename to src/shared/optypes/KeyWrapAlgorithm.scala diff --git a/src/optypes/LockOwner.scala b/src/shared/optypes/LockOwner.scala similarity index 100% rename from src/optypes/LockOwner.scala rename to src/shared/optypes/LockOwner.scala diff --git a/src/optypes/LockResourceId.scala b/src/shared/optypes/LockResourceId.scala similarity index 100% rename from src/optypes/LockResourceId.scala rename to src/shared/optypes/LockResourceId.scala diff --git a/src/optypes/LockStoreName.scala b/src/shared/optypes/LockStoreName.scala similarity index 100% rename from src/optypes/LockStoreName.scala rename to src/shared/optypes/LockStoreName.scala diff --git a/src/optypes/MetadataKey.scala b/src/shared/optypes/MetadataKey.scala similarity index 100% rename from src/optypes/MetadataKey.scala rename to src/shared/optypes/MetadataKey.scala diff --git a/src/optypes/MetadataValue.scala b/src/shared/optypes/MetadataValue.scala similarity index 100% rename from src/optypes/MetadataValue.scala rename to src/shared/optypes/MetadataValue.scala diff --git a/src/optypes/ModelName.scala b/src/shared/optypes/ModelName.scala similarity index 100% rename from src/optypes/ModelName.scala rename to src/shared/optypes/ModelName.scala diff --git a/src/shared/optypes/PemPath.scala b/src/shared/optypes/PemPath.scala new file mode 100644 index 0000000..a6d98b1 --- /dev/null +++ b/src/shared/optypes/PemPath.scala @@ -0,0 +1,19 @@ +package dapr4s + +import language.experimental.safe + +/** Filesystem path to a PEM file (TLS certificate, private key, or CA bundle). + * + * Used by [[SidecarConfig]] for the gRPC TLS material. Modelled as a string rather than `java.nio.file.Path` so the + * configuration cross-compiles to Scala.js, where `java.nio.file` does not exist. On the JVM, + * `PemPath(path: java.nio.file.Path)` also works directly (a jvm-only companion extension — see + * `src/jvm/PemPathJvm.scala`); the reverse direction is `java.nio.file.Path.of(pemPath.value)`. + * + * Must not be empty. + */ +opaque type PemPath = String +object PemPath: + def apply(s: String): PemPath = + require(s.nonEmpty, "PemPath must not be empty") + s + extension (p: PemPath) def value: String = p diff --git a/src/optypes/PubSubName.scala b/src/shared/optypes/PubSubName.scala similarity index 100% rename from src/optypes/PubSubName.scala rename to src/shared/optypes/PubSubName.scala diff --git a/src/optypes/ReminderName.scala b/src/shared/optypes/ReminderName.scala similarity index 100% rename from src/optypes/ReminderName.scala rename to src/shared/optypes/ReminderName.scala diff --git a/src/optypes/Route.scala b/src/shared/optypes/Route.scala similarity index 100% rename from src/optypes/Route.scala rename to src/shared/optypes/Route.scala diff --git a/src/optypes/SecretKey.scala b/src/shared/optypes/SecretKey.scala similarity index 100% rename from src/optypes/SecretKey.scala rename to src/shared/optypes/SecretKey.scala diff --git a/src/optypes/SecretStoreName.scala b/src/shared/optypes/SecretStoreName.scala similarity index 100% rename from src/optypes/SecretStoreName.scala rename to src/shared/optypes/SecretStoreName.scala diff --git a/src/optypes/SecretValue.scala b/src/shared/optypes/SecretValue.scala similarity index 100% rename from src/optypes/SecretValue.scala rename to src/shared/optypes/SecretValue.scala diff --git a/src/optypes/SerializedJson.scala b/src/shared/optypes/SerializedJson.scala similarity index 100% rename from src/optypes/SerializedJson.scala rename to src/shared/optypes/SerializedJson.scala diff --git a/src/optypes/StateConcurrency.scala b/src/shared/optypes/StateConcurrency.scala similarity index 100% rename from src/optypes/StateConcurrency.scala rename to src/shared/optypes/StateConcurrency.scala diff --git a/src/optypes/StateConsistency.scala b/src/shared/optypes/StateConsistency.scala similarity index 100% rename from src/optypes/StateConsistency.scala rename to src/shared/optypes/StateConsistency.scala diff --git a/src/optypes/StateQuery.scala b/src/shared/optypes/StateQuery.scala similarity index 100% rename from src/optypes/StateQuery.scala rename to src/shared/optypes/StateQuery.scala diff --git a/src/optypes/StateStoreKey.scala b/src/shared/optypes/StateStoreKey.scala similarity index 100% rename from src/optypes/StateStoreKey.scala rename to src/shared/optypes/StateStoreKey.scala diff --git a/src/optypes/StateStoreName.scala b/src/shared/optypes/StateStoreName.scala similarity index 100% rename from src/optypes/StateStoreName.scala rename to src/shared/optypes/StateStoreName.scala diff --git a/src/optypes/TimerName.scala b/src/shared/optypes/TimerName.scala similarity index 100% rename from src/optypes/TimerName.scala rename to src/shared/optypes/TimerName.scala diff --git a/src/optypes/ToolCallId.scala b/src/shared/optypes/ToolCallId.scala similarity index 100% rename from src/optypes/ToolCallId.scala rename to src/shared/optypes/ToolCallId.scala diff --git a/src/optypes/ToolName.scala b/src/shared/optypes/ToolName.scala similarity index 100% rename from src/optypes/ToolName.scala rename to src/shared/optypes/ToolName.scala diff --git a/src/optypes/Topic.scala b/src/shared/optypes/Topic.scala similarity index 100% rename from src/optypes/Topic.scala rename to src/shared/optypes/Topic.scala diff --git a/src/optypes/WorkflowInstanceId.scala b/src/shared/optypes/WorkflowInstanceId.scala similarity index 100% rename from src/optypes/WorkflowInstanceId.scala rename to src/shared/optypes/WorkflowInstanceId.scala diff --git a/src/optypes/WorkflowName.scala b/src/shared/optypes/WorkflowName.scala similarity index 100% rename from src/optypes/WorkflowName.scala rename to src/shared/optypes/WorkflowName.scala diff --git a/test/integration/CryptoCapabilityServerTest.scala b/test/integration/CryptoCapabilityServerTest.scala deleted file mode 100644 index bba8111..0000000 --- a/test/integration/CryptoCapabilityServerTest.scala +++ /dev/null @@ -1,75 +0,0 @@ -package dapr4s.test.integration - -import dapr4s.* -import dapr4s.given -import io.dapr.testcontainers.{Component, DaprContainer} -import com.dimafeng.testcontainers.munit.TestContainersForAll -import munit.FunSuite -import unsafeExceptions.canThrowAny - -import java.security.KeyPairGenerator -import java.util.Base64 - -/** Tests for [[CryptoCapability]] against Dapr's `crypto.dapr.localstorage` component, backed by an RSA key generated - * at test time and mounted into the container. Verifies the encrypt → decrypt round trip over the real alpha1 - * streaming wire API. - */ -@scala.caps.assumeSafe -class CryptoCapabilityServerTest extends FunSuite with TestContainersForAll: - - type Containers = DaprTestContainer - - private val KeyName = "rsa-key" - - override def startContainers(): DaprTestContainer = - val keyDir = java.nio.file.Files.createTempDirectory("dapr4s-crypto-keys").nn - val keyFile = keyDir.resolve(KeyName) - java.nio.file.Files.write(keyFile, generateRsaPrivateKeyPem().getBytes("UTF-8").nn) - // daprd runs as a non-root user inside the container; make the dir/key world-readable so the - // crypto component can load the key (otherwise: "open /keys/rsa-key: permission denied"). - java.nio.file.Files - .setPosixFilePermissions(keyDir, java.nio.file.attribute.PosixFilePermissions.fromString("rwxr-xr-x")) - java.nio.file.Files - .setPosixFilePermissions(keyFile, java.nio.file.attribute.PosixFilePermissions.fromString("rw-r--r--")) - - val c = DaprTestContainer( - DaprContainer(DaprTestContainer.DefaultImage) - .withAppName("crypto-server-test") - .withAppPort(0) - .withCopyFileToContainer( - org.testcontainers.utility.MountableFile.forHostPath(keyDir, 0x1ed), // 0755 - "/keys", - ) - .withComponent(Component("localstorage", "crypto.dapr.localstorage", "v1", java.util.Map.of("path", "/keys"))), - ) - c.start() - c - - private def generateRsaPrivateKeyPem(): String = - val kpg = KeyPairGenerator.getInstance("RSA").nn - kpg.initialize(2048) - val kp = kpg.generateKeyPair().nn - val der = kp.getPrivate.nn.getEncoded.nn - val b64 = Base64.getMimeEncoder(64, Array[Byte]('\n')).nn.encodeToString(der) - s"-----BEGIN PRIVATE KEY-----\n$b64\n-----END PRIVATE KEY-----\n" - - test("crypto: encryptString then decryptString round-trips the original text"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - DaprCapability.crypto(CryptoComponentName("localstorage")) { - val plaintext = "the quick brown fox" - val cipher = CryptoCapability.encryptString(CryptoKeyName(KeyName), plaintext, KeyWrapAlgorithm.Rsa) - assert(cipher.nonEmpty, "ciphertext should not be empty") - assertEquals(CryptoCapability.decryptString(cipher), plaintext) - } - } - - test("crypto: encrypt then decrypt round-trips raw bytes"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - DaprCapability.crypto(CryptoComponentName("localstorage")) { - val data = Charsets.encodeString("payload-bytes", Charsets.Utf8) - val cipher = CryptoCapability.encrypt(CryptoKeyName(KeyName), data, KeyWrapAlgorithm.Rsa) - assertEquals(CryptoCapability.decrypt(cipher), data) - } - } diff --git a/test/integration/InvokeCapabilityServerTest.scala b/test/integration/InvokeCapabilityServerTest.scala deleted file mode 100644 index e61e9d9..0000000 --- a/test/integration/InvokeCapabilityServerTest.scala +++ /dev/null @@ -1,138 +0,0 @@ -package dapr4s.test.integration - -import dapr4s.* -import dapr4s.given -import dapr4s.internal.DaprAppServer -import dapr4s.test.unit.DaprServerTestBase -import dapr4s.test.integration.apps.* -import io.dapr.testcontainers.{DaprContainer, Component} -import com.dimafeng.testcontainers.munit.TestContainersForAll -import munit.FunSuite -import unsafeExceptions.canThrowAny -import java.util.Collections - -/** Integration tests for [[InvokeCapability]] using the self-invoke pattern. - * - * A [[DaprAppServer]] registers several [[InvokeRoute]] handlers. The Dapr sidecar is configured to route to the same - * app server. Tests then call [[InvokeCapability.invoke]] via the sidecar, which proxies the request back to the app — - * exercising the full sidecar ↔ app invocation path. - * - * Because the sidecar needs a reachable target, the app server must be started before the sidecar (same two-phase - * pattern as [[ActorCapabilityServerTest]]). - */ -@scala.caps.assumeSafe -class InvokeCapabilityServerTest extends FunSuite with TestContainersForAll with DaprServerTestBase: - - type Containers = DaprTestContainer - - private val appPort: Int = - val s = java.net.ServerSocket(0) - val p = s.getLocalPort - s.close() - p - - private var appServerThread: Option[Thread] = None - - override def afterAll(): Unit = - super.afterAll() - appServerThread.foreach { t => t.interrupt(); t.join(2000) } - - // The set of routes registered on the app server used for all tests in this suite. - private val echoApp = DaprApp( - invokeRoutes = List( - InvokeRoute[String, String](InvokeMethodName("echo")) { s => s }, - InvokeRoute[IncrRequest, CounterState](InvokeMethodName("double")) { req => - CounterState(req.amount * 2) - }, - ), - ) - - override def startContainers(): DaprTestContainer = - // Make the host-side app server reachable from inside Docker containers. - org.testcontainers.Testcontainers.exposeHostPorts(appPort) - - // Start the app server BEFORE the sidecar so the sidecar can call /dapr/config and route invokeRoutes. - val server = new DaprAppServer(echoApp) - appServerThread = Some( - Thread.ofVirtual().start(() => server.startAndBlock(appPort, TestDapr.placeholderCapability)), - ) - waitForPort(appPort, 5000) - - val c = DaprTestContainer( - DaprContainer(DaprTestContainer.DefaultImage) - .withNetwork(org.testcontainers.containers.Network.SHARED) - .withAppName("svc-invoke-test") - .withAppPort(appPort) - .withAppChannelAddress("host.testcontainers.internal") - .withComponent( - Component("statestore", "state.in-memory", "v1", Collections.emptyMap()), - ), - ) - c.start() - - // Wait for the sidecar to become fully healthy before running tests. - waitForSidecarHealth(c.httpEndpoint.getPort) - - c - - private def waitForSidecarHealth(sidecarPort: Int, maxMs: Int = 30000): Unit = - val url = s"http://localhost:$sidecarPort/v1.0/healthz" - val deadline = System.currentTimeMillis() + maxMs - var lastMsg = "" - while System.currentTimeMillis() < deadline do - try - val conn = java.net.URI.create(url).toURL.nn.openConnection().asInstanceOf[java.net.HttpURLConnection] - conn.setRequestMethod("GET") - conn.setConnectTimeout(1000) - conn.setReadTimeout(2000) - conn.connect() - val code = conn.getResponseCode - conn.disconnect() - lastMsg = s"status=$code" - if code == 204 then return - catch - case e: java.net.SocketTimeoutException => - lastMsg = "timeout" - case e: Exception => - lastMsg = Option(e.getMessage).getOrElse(e.getClass.getName) - Thread.sleep(200) - throw RuntimeException(s"Sidecar not healthy after ${maxMs}ms — last=$lastMsg") - - private val selfAppId = AppId("svc-invoke-test") - - // ---- POST invocations ------------------------------------------------------- - - test("invoke: POST self-invocation round-trips string payload"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val invoke = summon[DaprCapability].invoke - val result = invoke.invoke(selfAppId, InvokeMethodName("echo"), "hello")[String] - assertEquals(result, "hello") - } - - test("invoke: POST self-invocation with structured data"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val invoke = summon[DaprCapability].invoke - val result = invoke.invoke(selfAppId, InvokeMethodName("double"), IncrRequest(5))[CounterState] - assertEquals(result, CounterState(10)) - } - - test("invoke: POST self-invocation returns correct structured response for another value"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val invoke = summon[DaprCapability].invoke - val result = invoke.invoke(selfAppId, InvokeMethodName("double"), IncrRequest(7))[CounterState] - assertEquals(result, CounterState(14)) - } - - // ---- derived client (dapr4s.derivation.Invoke) ------------------- - - test("invoke: derived EchoService round-trips through the sidecar"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - DaprCapability.invoke: - val svc = EchoService(selfAppId) - assertEquals(svc.echo("hello"), "hello") - assertEquals(svc.double(IncrRequest(6)), CounterState(12)) - } diff --git a/test/integration/InvokeIntegrationTest.scala b/test/integration/InvokeIntegrationTest.scala deleted file mode 100644 index 2ddf383..0000000 --- a/test/integration/InvokeIntegrationTest.scala +++ /dev/null @@ -1,44 +0,0 @@ -package dapr4s.test.integration - -import dapr4s.* -import dapr4s.given -import io.dapr.testcontainers.DaprContainer -import com.dimafeng.testcontainers.munit.TestContainersForAll -import munit.FunSuite - -/** Integration tests for [[InvokeCapability]]. - * - * Service invocation requires a running target application. These tests verify that the wrapper correctly propagates - * the [[DaprException]] from the sidecar when no target is available (expected in CI without a real peer app). - */ -@scala.caps.assumeSafe -class InvokeIntegrationTest extends FunSuite with TestContainersForAll: - - type Containers = DaprTestContainer - - override def startContainers(): DaprTestContainer = - val c = DaprTestContainer( - DaprContainer(DaprTestContainer.DefaultImage) - .withAppName("invoke-test-app") - .withAppPort(0), - ) - c.start() - c - - // ------------------------------------------------------------------------- - - test("integration: invoke non-existent app throws DaprException"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val invoke = summon[DaprCapability].invoke - intercept[io.dapr.exceptions.DaprException]: - invoke.invoke(AppId("no-such-app"), InvokeMethodName("method"), "data")[String] - } - - test("integration: invokeGet non-existent app throws DaprException"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val invoke = summon[DaprCapability].invoke - intercept[io.dapr.exceptions.DaprException]: - invoke.invoke[String](AppId("no-such-app"), InvokeMethodName("method")) - } diff --git a/test/integration/LockCapabilityServerTest.scala b/test/integration/LockCapabilityServerTest.scala deleted file mode 100644 index 4232e31..0000000 --- a/test/integration/LockCapabilityServerTest.scala +++ /dev/null @@ -1,207 +0,0 @@ -package dapr4s.test.integration - -import dapr4s.* -import dapr4s.given -import dapr4s.test.unit.DaprServerTestBase -import io.dapr.testcontainers.{DaprContainer, Component} -import com.dimafeng.testcontainers.GenericContainer -import com.dimafeng.testcontainers.lifecycle.and -import com.dimafeng.testcontainers.lifecycle.Andable.AndableOps -import com.dimafeng.testcontainers.munit.TestContainersForAll -import org.testcontainers.containers.Network -import org.testcontainers.containers.wait.strategy.Wait -import munit.FunSuite -import unsafeExceptions.canThrowAny -import scala.concurrent.duration.DurationInt - -/** Tests for every [[LockCapability]] method through real [[dapr4s.internal.DaprAppServer]] HTTP dispatch, backed by a - * real `lock.redis` component via Testcontainers. - * - * Each test uses a unique resource ID (UUID) so tests sharing the same Dapr sidecar container do not interfere. - */ -@scala.caps.assumeSafe -class LockCapabilityServerTest extends FunSuite with TestContainersForAll with DaprServerTestBase: - - type Containers = GenericContainer and DaprTestContainer - - override def startContainers(): GenericContainer and DaprTestContainer = - val network = Network.newNetwork() - - val redis = GenericContainer( - dockerImage = "redis:7-alpine", - exposedPorts = Seq(6379), - waitStrategy = Wait.forLogMessage(".*Ready to accept connections.*", 1), - ) - redis.container.withNetwork(network) - redis.container.withNetworkAliases("redis") - redis.start() - - val c = DaprTestContainer( - DaprContainer(DaprTestContainer.DefaultImage) - .withNetwork(network) - .withAppName("lock-server-test") - .withAppPort(0) - .withComponent(Component("lockstore", "lock.redis", "v1", java.util.Map.of("redisHost", "redis:6379"))) - .dependsOn(redis.container), - ) - c.start() - redis and c - - private def uniqueResource() = LockResourceId(s"res-${java.util.UUID.randomUUID()}") - private def uniqueOwner() = LockOwner(s"owner-${java.util.UUID.randomUUID()}") - - // ---- tryLock --------------------------------------------------------------- - - test("lock: tryLock on free resource returns true"): - withContainers { case _ and c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val res = uniqueResource() - val own = uniqueOwner() - DaprCapability.lock(LockStoreName("lockstore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[Unit, Boolean](InvokeMethodName("lock")) { _ => - try LockCapability.tryLock(res, own, 30.seconds) - catch case e: Exception => throw e - }, - ), - ), - ) { port => - val result = JsonCodec.decodeOrThrow[Boolean](httpPost(s"http://localhost:$port/lock", "null")) - assert(result, "Expected tryLock to succeed on free resource") - } - } - } - - test("lock: tryLock on held resource returns false"): - withContainers { case _ and c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val res = uniqueResource() - DaprCapability.lock(LockStoreName("lockstore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[String, Boolean](InvokeMethodName("lock")) { ownerStr => - try LockCapability.tryLock(res, LockOwner(ownerStr), 30.seconds) - catch case e: Exception => throw e - }, - ), - ), - ) { port => - val r1 = JsonCodec.decodeOrThrow[Boolean](httpPost(s"http://localhost:$port/lock", "\"owner-1\"")) - val r2 = JsonCodec.decodeOrThrow[Boolean](httpPost(s"http://localhost:$port/lock", "\"owner-2\"")) - assert(r1, "First tryLock should succeed") - assert(!r2, "Second tryLock should fail — resource already held") - } - } - } - - // ---- unlock ---------------------------------------------------------------- - - test("lock: unlock by correct owner returns Success"): - withContainers { case _ and c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val res = uniqueResource() - val own = uniqueOwner() - DaprCapability.lock(LockStoreName("lockstore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[Unit, Boolean](InvokeMethodName("acquire")) { _ => - try LockCapability.tryLock(res, own, 30.seconds) - catch case e: Exception => throw e - }, - InvokeRoute[Unit, String](InvokeMethodName("release")) { _ => - try LockCapability.unlock(res, own).toString - catch case e: Exception => throw e - }, - ), - ), - ) { port => - httpPost(s"http://localhost:$port/acquire", "null") - val status = JsonCodec.decodeOrThrow[String](httpPost(s"http://localhost:$port/release", "null")) - assertEquals(status, "Success") - } - } - } - - test("lock: unlock on non-existent lock returns LockNotFound"): - withContainers { case _ and c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val res = uniqueResource() - val own = uniqueOwner() - DaprCapability.lock(LockStoreName("lockstore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[Unit, String](InvokeMethodName("release")) { _ => - try LockCapability.unlock(res, own).toString - catch case e: Exception => throw e - }, - ), - ), - ) { port => - val status = JsonCodec.decodeOrThrow[String](httpPost(s"http://localhost:$port/release", "null")) - assertEquals(status, "LockNotFound") - } - } - } - - test("lock: unlock by wrong owner returns InternalError"): - withContainers { case _ and c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val res = uniqueResource() - val realOwner = uniqueOwner() - DaprCapability.lock(LockStoreName("lockstore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[Unit, Boolean](InvokeMethodName("acquire")) { _ => - try LockCapability.tryLock(res, realOwner, 30.seconds) - catch case e: Exception => throw e - }, - InvokeRoute[Unit, String](InvokeMethodName("release-wrong")) { _ => - try LockCapability.unlock(res, LockOwner("intruder")).toString - catch case e: Exception => throw e - }, - ), - ), - ) { port => - httpPost(s"http://localhost:$port/acquire", "null") - val status = JsonCodec.decodeOrThrow[String](httpPost(s"http://localhost:$port/release-wrong", "null")) - assertEquals(status, "InternalError") - } - } - } - - // ---- re-lock after unlock -------------------------------------------------- - - test("lock: re-lock after successful unlock succeeds"): - withContainers { case _ and c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val res = uniqueResource() - val own = uniqueOwner() - DaprCapability.lock(LockStoreName("lockstore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[Unit, Boolean](InvokeMethodName("lock")) { _ => - try LockCapability.tryLock(res, own, 30.seconds) - catch case e: Exception => throw e - }, - InvokeRoute[Unit, String](InvokeMethodName("unlock")) { _ => - try LockCapability.unlock(res, own).toString - catch case e: Exception => throw e - }, - ), - ), - ) { port => - val r1 = JsonCodec.decodeOrThrow[Boolean](httpPost(s"http://localhost:$port/lock", "null")) - assert(r1) - httpPost(s"http://localhost:$port/unlock", "null") - val r2 = JsonCodec.decodeOrThrow[Boolean](httpPost(s"http://localhost:$port/lock", "null")) - assert(r2, "Should be able to acquire lock again after releasing it") - } - } - } diff --git a/test/integration/SecretsCapabilityServerTest.scala b/test/integration/SecretsCapabilityServerTest.scala deleted file mode 100644 index 9f24459..0000000 --- a/test/integration/SecretsCapabilityServerTest.scala +++ /dev/null @@ -1,119 +0,0 @@ -package dapr4s.test.integration - -import dapr4s.* -import dapr4s.given -import dapr4s.test.unit.DaprServerTestBase -import io.dapr.testcontainers.{DaprContainer, Component} -import com.dimafeng.testcontainers.munit.TestContainersForAll -import munit.FunSuite -import unsafeExceptions.canThrowAny - -import java.util.Collections - -/** Tests for every [[SecretsCapability]] method through real [[dapr4s.internal.DaprAppServer]] HTTP dispatch, backed by - * a real `secretstores.local.env` component that reads from the Dapr container's environment variables. - * - * Two secrets are pre-seeded via `addEnv` when the container is created. - */ -@scala.caps.assumeSafe -class SecretsCapabilityServerTest extends FunSuite with TestContainersForAll with DaprServerTestBase: - - type Containers = DaprTestContainer - - private val SeededKey = "SSDAPR_TEST_SECRET_A" - private val SeededValue = "secret-value-alpha" - private val SeededKey2 = "SSDAPR_TEST_SECRET_B" - private val SeededValue2 = "secret-value-beta" - - override def startContainers(): DaprTestContainer = - val dc = DaprContainer(DaprTestContainer.DefaultImage) - .withAppName("secrets-server-test") - .withAppPort(0) - .withComponent(Component("envstore", "secretstores.local.env", "v1", Collections.emptyMap())) - dc.addEnv(SeededKey, SeededValue) - dc.addEnv(SeededKey2, SeededValue2) - val c = DaprTestContainer(dc) - c.start() - c - - // ---- get ------------------------------------------------------------------- - - test("secrets: get returns seeded env var value"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - DaprCapability.secrets(SecretStoreName("envstore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[String, Option[SecretValue]](InvokeMethodName("get")) { key => - try SecretsCapability.get(SecretKey(key)) - catch case e: Exception => throw e - }, - ), - ), - ) { port => - val resp = JsonCodec.decodeOrThrow[Option[SecretValue]]( - httpPost(s"http://localhost:$port/get", s""""$SeededKey""""), - ) - assertEquals(resp, Some(SecretValue(SeededValue))) - } - } - } - - test("secrets: get distinguishes between two seeded keys"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - DaprCapability.secrets(SecretStoreName("envstore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[String, Option[SecretValue]](InvokeMethodName("get")) { key => - try SecretsCapability.get(SecretKey(key)) - catch case e: Exception => throw e - }, - ), - ), - ) { port => - val r1 = - JsonCodec.decodeOrThrow[Option[SecretValue]](httpPost(s"http://localhost:$port/get", s""""$SeededKey"""")) - val r2 = - JsonCodec.decodeOrThrow[Option[SecretValue]]( - httpPost(s"http://localhost:$port/get", s""""$SeededKey2""""), - ) - assertEquals(r1, Some(SecretValue(SeededValue))) - assertEquals(r2, Some(SecretValue(SeededValue2))) - } - } - } - - // ---- getBulk --------------------------------------------------------------- - - test("secrets: getBulk result contains seeded env var keys"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - DaprCapability.secrets(SecretStoreName("envstore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[Unit, Map[String, String]](InvokeMethodName("bulk")) { _ => - try SecretsCapability.getBulk().map { case (k, v) => k.value -> v.value } - catch case e: Exception => throw e - }, - ), - ), - ) { port => - val bulk = JsonCodec.decodeOrThrow[Map[String, String]]( - httpPost(s"http://localhost:$port/bulk", "null"), - ) - // local.env getBulk returns keys in "NAME/NAME" format from the nested subKey structure - assert( - bulk.exists { case (k, v) => k.contains(SeededKey) && v == SeededValue }, - s"Expected $SeededKey=$SeededValue in bulk result; got keys: ${bulk.keys.filter(_.startsWith("SSDAPR")).toList}", - ) - assert( - bulk.exists { case (k, v) => k.contains(SeededKey2) && v == SeededValue2 }, - s"Expected $SeededKey2=$SeededValue2 in bulk result", - ) - } - } - } diff --git a/test/integration/SecretsIntegrationTest.scala b/test/integration/SecretsIntegrationTest.scala deleted file mode 100644 index a7c8d05..0000000 --- a/test/integration/SecretsIntegrationTest.scala +++ /dev/null @@ -1,43 +0,0 @@ -package dapr4s.test.integration - -import dapr4s.* -import dapr4s.given -import io.dapr.testcontainers.DaprContainer -import com.dimafeng.testcontainers.munit.TestContainersForAll -import munit.FunSuite - -/** Integration tests for [[SecretsCapability]]. - * - * These tests verify the error path — calling against a component that does not exist surfaces [[DaprException]]. - */ -@scala.caps.assumeSafe -class SecretsIntegrationTest extends FunSuite with TestContainersForAll: - - type Containers = DaprTestContainer - - override def startContainers(): DaprTestContainer = - val c = DaprTestContainer( - DaprContainer(DaprTestContainer.DefaultImage) - .withAppName("secrets-test-app") - .withAppPort(0), - ) - c.start() - c - - // ------------------------------------------------------------------------- - - test("integration: get from non-configured secrets store throws DaprException"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val secrets = summon[DaprCapability].secrets(SecretStoreName("nonexistent-store")) - intercept[io.dapr.exceptions.DaprException]: - secrets.get(SecretKey("any-key")) - } - - test("integration: getBulk from non-configured secrets store throws DaprException"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val secrets = summon[DaprCapability].secrets(SecretStoreName("nonexistent-store")) - intercept[io.dapr.exceptions.DaprException]: - secrets.getBulk() - } diff --git a/test/integration/StateCapabilityServerTest.scala b/test/integration/StateCapabilityServerTest.scala deleted file mode 100644 index cc0de87..0000000 --- a/test/integration/StateCapabilityServerTest.scala +++ /dev/null @@ -1,410 +0,0 @@ -package dapr4s.test.integration - -import dapr4s.* -import dapr4s.given -import dapr4s.test.unit.DaprServerTestBase -import io.dapr.testcontainers.{DaprContainer, Component} -import com.dimafeng.testcontainers.munit.TestContainersForAll -import munit.FunSuite -import unsafeExceptions.canThrowAny - -import java.util.Collections - -/** Tests for every [[StateCapability]] method through real [[dapr4s.internal.DaprAppServer]] HTTP dispatch, backed by a - * real Dapr in-memory state store via Testcontainers. - * - * Each test wraps its state operations in an [[InvokeRoute]] handler, starts the HTTP server, POSTs a request, and - * asserts on the JSON response — the same path a real Dapr client would take. - */ -@scala.caps.assumeSafe -class StateCapabilityServerTest extends FunSuite with TestContainersForAll with DaprServerTestBase: - - type Containers = DaprTestContainer - - override def startContainers(): DaprTestContainer = - val c = DaprTestContainer( - DaprContainer(DaprTestContainer.DefaultImage) - .withAppName("state-server-test") - .withAppPort(0) - .withComponent(Component("statestore", "state.in-memory", "v1", Collections.emptyMap())), - ) - c.start() - c - - private def uniqueKey() = s"k-${java.util.UUID.randomUUID()}" - - // ---- get / save ----------------------------------------------------------- - - test("state: save then get returns saved value"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val k = uniqueKey() - DaprCapability.state(StateStoreName("statestore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[String, String](InvokeMethodName("save")) { v => - try { StateCapability.save(StateStoreKey(k), v); "ok" } - catch case e: Exception => throw e - }, - InvokeRoute[Unit, Option[String]](InvokeMethodName("get")) { _ => - try StateCapability.get[String](StateStoreKey(k)) - catch case e: Exception => throw e - }, - ), - ), - ) { port => - httpPost(s"http://localhost:$port/save", "\"hello\"") - val resp = httpPost(s"http://localhost:$port/get", "null") - assert(resp.contains("hello"), s"Expected hello, got: $resp") - } - } - } - - test("state: get missing key returns null"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - DaprCapability.state(StateStoreName("statestore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[Unit, Option[String]](InvokeMethodName("get")) { _ => - try StateCapability.get[String](StateStoreKey(s"absent-${java.util.UUID.randomUUID()}")) - catch case e: Exception => throw e - }, - ), - ), - ) { port => - assertEquals(httpPost(s"http://localhost:$port/get", "null"), "null") - } - } - } - - // ---- getWithETag ---------------------------------------------------------- - - test("state: getWithETag returns value and etag after save"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val k = uniqueKey() - DaprCapability.state(StateStoreName("statestore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[String, String](InvokeMethodName("save")) { v => - try { StateCapability.save(StateStoreKey(k), v); "ok" } - catch case e: Exception => throw e - }, - InvokeRoute[Unit, String](InvokeMethodName("get-with-etag")) { _ => - try - val e = StateCapability.getWithETag[String](StateStoreKey(k)) - s"${e.value.getOrElse("none")}|${e.etag.map(_.value).getOrElse("none")}" - catch case e: Exception => throw e - }, - ), - ), - ) { port => - httpPost(s"http://localhost:$port/save", "\"world\"") - val resp = JsonCodec.decodeOrThrow[String](httpPost(s"http://localhost:$port/get-with-etag", "null")) - val parts = resp.split("\\|", 2) - assertEquals(parts(0), "world") - assert(parts(1) != "none", "ETag should be present after save") - } - } - } - - test("state: getWithETag for missing key returns none|none"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - DaprCapability.state(StateStoreName("statestore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[Unit, String](InvokeMethodName("get-with-etag")) { _ => - try - val e = StateCapability.getWithETag[String](StateStoreKey(s"absent-${java.util.UUID.randomUUID()}")) - s"${e.value.getOrElse("none")}|${e.etag.map(_.value).getOrElse("none")}" - catch case ex: Exception => throw ex - }, - ), - ), - ) { port => - val resp = JsonCodec.decodeOrThrow[String](httpPost(s"http://localhost:$port/get-with-etag", "null")) - assertEquals(resp, "none|none") - } - } - } - - // ---- getBulk -------------------------------------------------------------- - - test("state: getBulk returns Some for present keys and None for absent"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val ka = uniqueKey() - val kb = uniqueKey() - DaprCapability.state(StateStoreName("statestore")) { - StateCapability.save(StateStoreKey(ka), "alpha") - StateCapability.save(StateStoreKey(kb), "beta") - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[List[String], List[Option[String]]](InvokeMethodName("bulk-get")) { keys => - try - val results = StateCapability.getBulk[String](keys.map(StateStoreKey(_))) - keys.map(k => results.get(StateStoreKey(k)).flatMap(_.value)) - catch case e: Exception => throw e - }, - ), - ), - ) { port => - val resp = - httpPost(s"http://localhost:$port/bulk-get", s"""["$ka","$kb","absent-${java.util.UUID.randomUUID()}"]""") - val list = JsonCodec.decodeOrThrow[List[Option[String]]](resp) - assertEquals(list, List(Some("alpha"), Some("beta"), None)) - } - } - } - - // ---- saveBulk ------------------------------------------------------------- - - test("state: saveBulk persists all entries"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val k1 = uniqueKey() - val k2 = uniqueKey() - val k3 = uniqueKey() - DaprCapability.state(StateStoreName("statestore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[Unit, String](InvokeMethodName("save-bulk")) { _ => - try - StateCapability.saveBulk[String]( - List(StateStoreKey(k1) -> "v1", StateStoreKey(k2) -> "v2", StateStoreKey(k3) -> "v3"), - ) - "ok" - catch case e: Exception => throw e - }, - InvokeRoute[String, Option[String]](InvokeMethodName("get")) { k => - try StateCapability.get[String](StateStoreKey(k)) - catch case e: Exception => throw e - }, - ), - ), - ) { port => - httpPost(s"http://localhost:$port/save-bulk", "null") - val r1 = JsonCodec.decodeOrThrow[Option[String]](httpPost(s"http://localhost:$port/get", s""""$k1"""")) - val r2 = JsonCodec.decodeOrThrow[Option[String]](httpPost(s"http://localhost:$port/get", s""""$k2"""")) - val r3 = JsonCodec.decodeOrThrow[Option[String]](httpPost(s"http://localhost:$port/get", s""""$k3"""")) - assertEquals(r1, Some("v1")) - assertEquals(r2, Some("v2")) - assertEquals(r3, Some("v3")) - } - } - } - - // ---- saveWithETag --------------------------------------------------------- - - test("state: saveWithETag with correct etag succeeds"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val k = uniqueKey() - DaprCapability.state(StateStoreName("statestore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[String, String](InvokeMethodName("seed")) { v => - try { StateCapability.save(StateStoreKey(k), v); "ok" } - catch case e: Exception => throw e - }, - InvokeRoute[Unit, String](InvokeMethodName("get-etag")) { _ => - try StateCapability.getWithETag[String](StateStoreKey(k)).etag.map(_.value).getOrElse("none") - catch case ex: Exception => throw ex - }, - InvokeRoute[String, String](InvokeMethodName("save-with-etag")) { etag => - try - val err = StateCapability.saveWithETag(StateStoreKey(k), "new-value", ETag(etag)) - if err.isDefined then "conflict" else "ok" - catch case e: Exception => throw e - }, - ), - ), - ) { port => - httpPost(s"http://localhost:$port/seed", "\"v1\"") - val etag = JsonCodec.decodeOrThrow[String](httpPost(s"http://localhost:$port/get-etag", "null")) - val result = JsonCodec.decodeOrThrow[String]( - httpPost(s"http://localhost:$port/save-with-etag", s""""$etag""""), - ) - assertEquals(result, "ok") - } - } - } - - test("state: saveWithETag with wrong etag returns conflict"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val k = uniqueKey() - DaprCapability.state(StateStoreName("statestore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[String, String](InvokeMethodName("seed")) { v => - try { StateCapability.save(StateStoreKey(k), v); "ok" } - catch case e: Exception => throw e - }, - InvokeRoute[Unit, String](InvokeMethodName("save-with-wrong-etag")) { _ => - try - val err = StateCapability.saveWithETag(StateStoreKey(k), "new", ETag("wrong-etag-999")) - if err.isDefined then "conflict" else "ok" - catch case e: Exception => throw e - }, - ), - ), - ) { port => - httpPost(s"http://localhost:$port/seed", "\"v1\"") - val result = JsonCodec.decodeOrThrow[String]( - httpPost(s"http://localhost:$port/save-with-wrong-etag", "null"), - ) - assertEquals(result, "conflict") - } - } - } - - // ---- delete / deleteWithETag ---------------------------------------------- - - test("state: delete removes a key"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val k = uniqueKey() - DaprCapability.state(StateStoreName("statestore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[String, String](InvokeMethodName("save")) { v => - try { StateCapability.save(StateStoreKey(k), v); "ok" } - catch case e: Exception => throw e - }, - InvokeRoute[Unit, String](InvokeMethodName("delete")) { _ => - try { StateCapability.delete(StateStoreKey(k)); "deleted" } - catch case e: Exception => throw e - }, - InvokeRoute[Unit, Option[String]](InvokeMethodName("get")) { _ => - try StateCapability.get[String](StateStoreKey(k)) - catch case e: Exception => throw e - }, - ), - ), - ) { port => - httpPost(s"http://localhost:$port/save", "\"bye\"") - httpPost(s"http://localhost:$port/delete", "null") - assertEquals(httpPost(s"http://localhost:$port/get", "null"), "null") - } - } - } - - test("state: deleteWithETag with correct etag removes key"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val k = uniqueKey() - DaprCapability.state(StateStoreName("statestore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[String, String](InvokeMethodName("seed")) { v => - try { StateCapability.save(StateStoreKey(k), v); "ok" } - catch case e: Exception => throw e - }, - InvokeRoute[Unit, String](InvokeMethodName("get-etag")) { _ => - try StateCapability.getWithETag[String](StateStoreKey(k)).etag.map(_.value).getOrElse("none") - catch case ex: Exception => throw ex - }, - InvokeRoute[String, String](InvokeMethodName("del-with-etag")) { etag => - try - val err = StateCapability.deleteWithETag(StateStoreKey(k), ETag(etag)) - if err.isDefined then "conflict" else "ok" - catch case e: Exception => throw e - }, - InvokeRoute[Unit, Option[String]](InvokeMethodName("get")) { _ => - try StateCapability.get[String](StateStoreKey(k)) - catch case e: Exception => throw e - }, - ), - ), - ) { port => - httpPost(s"http://localhost:$port/seed", "\"x\"") - val etag = JsonCodec.decodeOrThrow[String](httpPost(s"http://localhost:$port/get-etag", "null")) - val r = JsonCodec.decodeOrThrow[String](httpPost(s"http://localhost:$port/del-with-etag", s""""$etag"""")) - assertEquals(r, "ok") - assertEquals(httpPost(s"http://localhost:$port/get", "null"), "null") - } - } - } - - test("state: deleteWithETag with wrong etag leaves key"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val k = uniqueKey() - DaprCapability.state(StateStoreName("statestore")) { - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[String, String](InvokeMethodName("seed")) { v => - try { StateCapability.save(StateStoreKey(k), v); "ok" } - catch case e: Exception => throw e - }, - InvokeRoute[Unit, String](InvokeMethodName("del-wrong")) { _ => - try - val err = StateCapability.deleteWithETag(StateStoreKey(k), ETag("bad-etag")) - if err.isDefined then "conflict" else "ok" - catch case e: Exception => throw e - }, - InvokeRoute[Unit, Option[String]](InvokeMethodName("get")) { _ => - try StateCapability.get[String](StateStoreKey(k)) - catch case e: Exception => throw e - }, - ), - ), - ) { port => - httpPost(s"http://localhost:$port/seed", "\"stay\"") - val r = JsonCodec.decodeOrThrow[String](httpPost(s"http://localhost:$port/del-wrong", "null")) - assertEquals(r, "conflict") - assert(httpPost(s"http://localhost:$port/get", "null").contains("stay")) - } - } - } - - // ---- transaction ---------------------------------------------------------- - - test("state: transaction upserts and deletes atomically"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val kAdd = uniqueKey() - val kDel = uniqueKey() - DaprCapability.state(StateStoreName("statestore")) { - StateCapability.save(StateStoreKey(kDel), "gone") - withServer( - DaprApp(invokeRoutes = - List( - InvokeRoute[Unit, String](InvokeMethodName("tx")) { _ => - try - StateCapability.transaction( - Seq( - StateOp.UpsertOp[String](StateStoreKey(kAdd), "new"), - StateOp.DeleteOp(StateStoreKey(kDel)), - ), - ) - "ok" - catch case e: Exception => throw e - }, - InvokeRoute[String, Option[String]](InvokeMethodName("get")) { k => - try StateCapability.get[String](StateStoreKey(k)) - catch case e: Exception => throw e - }, - ), - ), - ) { port => - httpPost(s"http://localhost:$port/tx", "null") - assert(httpPost(s"http://localhost:$port/get", s""""$kAdd"""").contains("new")) - assertEquals(httpPost(s"http://localhost:$port/get", s""""$kDel""""), "null") - } - } - } diff --git a/test/integration/StateIntegrationTest.scala b/test/integration/StateIntegrationTest.scala deleted file mode 100644 index 26983ed..0000000 --- a/test/integration/StateIntegrationTest.scala +++ /dev/null @@ -1,115 +0,0 @@ -package dapr4s.test.integration - -import dapr4s.* -import dapr4s.given -import io.dapr.testcontainers.{DaprContainer, Component} -import com.dimafeng.testcontainers.munit.TestContainersForAll -import munit.FunSuite -import java.util.Collections - -/** Integration tests for [[StateCapability]] using a real DAPR sidecar in Docker via Testcontainers. - * - * Run with: `scala-cli test . -- --only "integration.*"` (Docker must be available.) - */ -@scala.caps.assumeSafe -class StateIntegrationTest extends FunSuite with TestContainersForAll: - - type Containers = DaprTestContainer - - override def startContainers(): DaprTestContainer = - val component = Component( - "kvstore", - "state.in-memory", - "v1", - Collections.emptyMap[String, String](), - ) - val c = DaprTestContainer( - DaprContainer(DaprTestContainer.DefaultImage) - .withAppName("state-test-app") - .withAppPort(0) - .withComponent(component), - ) - c.start() - c - - private def uniqueKey(): StateStoreKey = StateStoreKey(s"k-${System.nanoTime()}") - - // ------------------------------------------------------------------------- - // Tests — each receives the running container via withContainers - // ------------------------------------------------------------------------- - - test("integration: save and get"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val state = summon[DaprCapability].state(StateStoreName("kvstore")) - val key = uniqueKey() - state.save(key, "hello-integration") - assertEquals(state.get[String](key), Some("hello-integration")) - } - - test("integration: get missing key returns None"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val state = summon[DaprCapability].state(StateStoreName("kvstore")) - val v = state.get[String](StateStoreKey("definitely-does-not-exist-" + System.nanoTime())) - assertEquals(v, None) - } - - test("integration: getWithETag returns etag"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val state = summon[DaprCapability].state(StateStoreName("kvstore")) - val key = uniqueKey() - state.save(key, "v1") - val entry = state.getWithETag[String](key) - assertEquals(entry.value, Some("v1")) - assert(entry.etag.isDefined, "ETag should be present after save") - } - - test("integration: saveWithETag happy path"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val state = summon[DaprCapability].state(StateStoreName("kvstore")) - val key = uniqueKey() - state.save(key, "v1") - val etag = state.getWithETag[String](key).etag.getOrElse(fail("expected etag after save")) - assertEquals(state.saveWithETag(key, "v2", etag), None) - assertEquals(state.get[String](key), Some("v2")) - } - - test("integration: saveWithETag conflict returns Some(ETagMismatchException)"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val state = summon[DaprCapability].state(StateStoreName("kvstore")) - val key = uniqueKey() - state.save(key, "v1") - assert(state.saveWithETag(key, "v2", ETag("wrong-etag-999")).isDefined) - } - - test("integration: delete removes key"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val state = summon[DaprCapability].state(StateStoreName("kvstore")) - val key = uniqueKey() - state.save(key, "to-be-deleted") - state.delete(key) - assertEquals(state.get[String](key), None) - } - - test("integration: transaction upsert and delete"): - withContainers { c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - val state = summon[DaprCapability].state(StateStoreName("kvstore")) - val k1 = uniqueKey() - val k2 = uniqueKey() - state.save(k1, "will-be-deleted") - state.transaction( - Seq( - StateOp.UpsertOp[String](k2, "inserted-by-tx"), - StateOp.DeleteOp(k1), - ), - ) - assertEquals(state.get[String](k1), None) - // k2 may or may not be visible depending on transaction support in - // the in-memory state store; ensure no exception was thrown - } diff --git a/test/js/TestCodecsJs.scala b/test/js/TestCodecsJs.scala new file mode 100644 index 0000000..bee63b4 --- /dev/null +++ b/test/js/TestCodecsJs.scala @@ -0,0 +1,150 @@ +//> using target.platform "scala-js" +package dapr4s + +// Scala.js twin of TestCodecs.scala (which is JVM-only because it uses Jackson, a transitive +// dependency of the Dapr Java SDK). Provides the same test-only JsonCodec given instances, +// implemented over ujson (from upickle, a cross-platform test dependency), so the shared unit +// tests compile and run unchanged on the JS platform. +// +// Placed in `package dapr4s` so that `import dapr4s.*` (present in every test file) brings +// them into the implicit scope automatically — exactly like the JVM twin. +// +// WHY @assumeSafe on every given: the anonymous JsonCodec instances must carry an empty +// capture set so safe-mode test code can summon them freely; they close over nothing but +// pure ujson calls, so trusting them is sound. +// +// Note on Long: ujson backs numbers with Double, so longs beyond 2^53 lose precision here. +// That is inherent to JavaScript's JSON number model, not a codec bug; tests avoid such values. + +@scala.caps.assumeSafe +given JsonCodec[String] with + def encode(value: String): String = ujson.write(ujson.Str(value)) + def decode(json: String | Null): Either[JsonDecodeException, String] = + if json == null then Left(JsonDecodeException("null input")) + else + try + ujson.read(json) match + case ujson.Str(s) => Right(s) + case _ => Left(JsonDecodeException(s"expected JSON string, got: $json")) + catch case e: Exception => Left(JsonDecodeException(e.getMessage, e)) + +@scala.caps.assumeSafe +given JsonCodec[Int] with + def encode(value: Int): String = ujson.write(ujson.Num(value.toDouble)) + def decode(json: String | Null): Either[JsonDecodeException, Int] = + if json == null then Left(JsonDecodeException("null input")) + else + try + ujson.read(json) match + case ujson.Num(d) => Right(d.toInt) + case _ => Left(JsonDecodeException(s"expected JSON number, got: $json")) + catch case e: Exception => Left(JsonDecodeException(e.getMessage, e)) + +@scala.caps.assumeSafe +given JsonCodec[Long] with + def encode(value: Long): String = value.toString + def decode(json: String | Null): Either[JsonDecodeException, Long] = + if json == null then Left(JsonDecodeException("null input")) + else + try + ujson.read(json) match + case ujson.Num(d) => Right(d.toLong) + case _ => Left(JsonDecodeException(s"expected JSON number, got: $json")) + catch case e: Exception => Left(JsonDecodeException(e.getMessage, e)) + +@scala.caps.assumeSafe +given JsonCodec[Boolean] with + def encode(value: Boolean): String = ujson.write(ujson.Bool(value)) + def decode(json: String | Null): Either[JsonDecodeException, Boolean] = + if json == null then Left(JsonDecodeException("null input")) + else + try + ujson.read(json) match + case ujson.Bool(b) => Right(b) + case _ => Left(JsonDecodeException(s"expected JSON boolean, got: $json")) + catch case e: Exception => Left(JsonDecodeException(e.getMessage, e)) + +@scala.caps.assumeSafe +given JsonCodec[Double] with + def encode(value: Double): String = ujson.write(ujson.Num(value)) + def decode(json: String | Null): Either[JsonDecodeException, Double] = + if json == null then Left(JsonDecodeException("null input")) + else + try + ujson.read(json) match + case ujson.Num(d) => Right(d) + case _ => Left(JsonDecodeException(s"expected JSON number, got: $json")) + catch case e: Exception => Left(JsonDecodeException(e.getMessage, e)) + +@scala.caps.assumeSafe +given JsonCodec[Float] with + def encode(value: Float): String = ujson.write(ujson.Num(value.toDouble)) + def decode(json: String | Null): Either[JsonDecodeException, Float] = + if json == null then Left(JsonDecodeException("null input")) + else + try + ujson.read(json) match + case ujson.Num(d) => Right(d.toFloat) + case _ => Left(JsonDecodeException(s"expected JSON number, got: $json")) + catch case e: Exception => Left(JsonDecodeException(e.getMessage, e)) + +@scala.caps.assumeSafe +given JsonCodec[Unit] with + def encode(value: Unit): String = "null" + def decode(json: String | Null): Either[JsonDecodeException, Unit] = Right(()) + +@scala.caps.assumeSafe +given JsonCodec[SecretValue] with + def encode(value: SecretValue): String = ujson.write(ujson.Str(value.value)) + def decode(json: String | Null): Either[JsonDecodeException, SecretValue] = + if json == null then Left(JsonDecodeException("null input")) + else + try + ujson.read(json) match + case ujson.Str(s) => Right(SecretValue(s)) + case _ => Left(JsonDecodeException(s"expected JSON string, got: $json")) + catch case e: Exception => Left(JsonDecodeException(e.getMessage, e)) + +@scala.caps.assumeSafe +given [K: JsonCodec, V: JsonCodec]: JsonCodec[Map[K, V]] with + def encode(value: Map[K, V]): String = + value + .map: (k, v) => + val keyStr = summon[JsonCodec[K]].encode(k) + val valStr = summon[JsonCodec[V]].encode(v) + s"$keyStr:$valStr" + .mkString("{", ",", "}") + def decode(json: String | Null): Either[JsonDecodeException, Map[K, V]] = + if json == null then Left(JsonDecodeException("null input")) + else + try + ujson.read(json) match + case ujson.Obj(entries) => + val kCodec = summon[JsonCodec[K]] + val vCodec = summon[JsonCodec[V]] + entries.toList.foldRight(Right(Map.empty): Either[JsonDecodeException, Map[K, V]]): + case ((key, value), Right(acc)) => + for + k <- kCodec.decode(ujson.write(ujson.Str(key))) + v <- vCodec.decode(ujson.write(value)) + yield acc + (k -> v) + case (_, left) => left + case _ => Left(JsonDecodeException(s"expected JSON object, got: $json")) + catch case e: Exception => Left(JsonDecodeException(e.getMessage, e)) + +@scala.caps.assumeSafe +given [T: JsonCodec]: JsonCodec[List[T]] with + def encode(value: List[T]): String = + value.map(summon[JsonCodec[T]].encode).mkString("[", ",", "]") + def decode(json: String | Null): Either[JsonDecodeException, List[T]] = + if json == null then Left(JsonDecodeException("null input")) + else + try + ujson.read(json) match + case ujson.Arr(items) => + val codec = summon[JsonCodec[T]] + items.toList.foldRight(Right(Nil): Either[JsonDecodeException, List[T]]): + case (elem, Right(acc)) => codec.decode(ujson.write(elem)).map(_ :: acc) + case (_, left) => left + case _ => Left(JsonDecodeException(s"expected JSON array, got: $json")) + catch case e: Exception => Left(JsonDecodeException(e.getMessage, e)) diff --git a/test/js/integration/ActorJsIntegrationTest.scala b/test/js/integration/ActorJsIntegrationTest.scala new file mode 100644 index 0000000..c3111a5 --- /dev/null +++ b/test/js/integration/ActorJsIntegrationTest.scala @@ -0,0 +1,71 @@ +//> using target.platform "scala-js" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import dapr4s.test.integration.apps.{CounterActorApp, CounterState, IncrRequest} +import munit.FunSuite +import scala.concurrent.duration.{Duration, DurationInt} +import scala.scalajs.js +import unsafeExceptions.canThrowAny +import JsItEnv.* + +/** [[ActorCapability]] against the `Counter` actor hosted by the JS test server ([[CounterActorApp]] — the same + * cross-platform fixture the JVM [[ActorCapabilityServerTest]] hosts), with actor state in the real `state.redis` + * actor state store and the placement service from the harness. + * + * The first call retries: the sidecar answers 500 for actor invocations until the placement service has disseminated + * the actor type table — the same startup race the JVM twin polls through. + */ +@scala.caps.assumeSafe +class ActorJsIntegrationTest extends FunSuite: + + override def munitTimeout: Duration = 120.seconds + + private def uniqueActorId() = ActorId(s"js-it-actor-${uniqueId()}") + + test("actor: increments accumulate and get reads the final count"): + js.async { + Dapr(clientConfig).run: + DaprCapability.actor(CounterActorApp.ActorTypeName, uniqueActorId()) { + val first = retryUntilSuccess("actor placement ready") { + ActorCapability.invoke(ActorMethodName("increment"), IncrRequest(2))[CounterState] + } + assertEquals(first, CounterState(2)) + val second = ActorCapability.invoke(ActorMethodName("increment"), IncrRequest(3))[CounterState] + assertEquals(second, CounterState(5)) + val read = ActorCapability.invoke(ActorMethodName("get"), ())[CounterState] + assertEquals(read, CounterState(5)) + } + }.toFuture + + test("actor: state is isolated per actor id"): + js.async { + Dapr(clientConfig).run: + val cap = summon[DaprCapability] + val idA = uniqueActorId() + val idB = uniqueActorId() + DaprCapability.actor(CounterActorApp.ActorTypeName, idA) { + val a = retryUntilSuccess("actor placement ready") { + ActorCapability.invoke(ActorMethodName("increment"), IncrRequest(11))[CounterState] + } + assertEquals(a, CounterState(11)) + }(using cap) + DaprCapability.actor(CounterActorApp.ActorTypeName, idB) { + val b = ActorCapability.invoke(ActorMethodName("get"), ())[CounterState] + assertEquals(b, CounterState(0)) + }(using cap) + }.toFuture + + test("actor: reset brings the count back to zero"): + js.async { + Dapr(clientConfig).run: + DaprCapability.actor(CounterActorApp.ActorTypeName, uniqueActorId()) { + val incremented = retryUntilSuccess("actor placement ready") { + ActorCapability.invoke(ActorMethodName("increment"), IncrRequest(100))[CounterState] + } + assertEquals(incremented, CounterState(100)) + assertEquals(ActorCapability.invoke(ActorMethodName("reset"), ())[CounterState], CounterState(0)) + assertEquals(ActorCapability.invoke(ActorMethodName("get"), ())[CounterState], CounterState(0)) + } + }.toFuture diff --git a/test/js/integration/ConfigurationJsIntegrationTest.scala b/test/js/integration/ConfigurationJsIntegrationTest.scala new file mode 100644 index 0000000..defbd8b --- /dev/null +++ b/test/js/integration/ConfigurationJsIntegrationTest.scala @@ -0,0 +1,29 @@ +//> using target.platform "scala-js" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import munit.FunSuite +import scala.concurrent.Future +import scala.concurrent.duration.{Duration, DurationInt} +import scala.scalajs.js +import unsafeExceptions.canThrowAny +import JsItEnv.* + +/** Scala.js (Wasm+JSPI) [[ConfigurationCapability]] integration suite: a thin shell over the shared + * [[ConfigurationScenarios]], run against the canonical `configuration.redis` store via the live sidecar. The JVM twin + * [[ConfigurationItTest]] runs the same scenarios. + * + * Configuration is gRPC-only in the JS SDK, so this suite also exercises the lazily created gRPC-protocol client end + * to end. + */ +@scala.caps.assumeSafe +class ConfigurationJsIntegrationTest extends FunSuite, ConfigurationScenarios: + + override def munitTimeout: Duration = 120.seconds + + private def run(body: DaprCapability ?=> Unit): Future[Unit] = + js.async(Dapr(clientConfig).run(body)).toFuture + + test("configuration: get returns the seeded items with values and versions")(run(getReturnsSeededItems)) + test("configuration: get for an unknown key returns no item for it")(run(getUnknownKeyReturnsNoItem)) diff --git a/test/js/integration/CryptoJsIntegrationTest.scala b/test/js/integration/CryptoJsIntegrationTest.scala new file mode 100644 index 0000000..888e5ae --- /dev/null +++ b/test/js/integration/CryptoJsIntegrationTest.scala @@ -0,0 +1,29 @@ +//> using target.platform "scala-js" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import munit.FunSuite +import scala.concurrent.Future +import scala.concurrent.duration.{Duration, DurationInt} +import scala.scalajs.js +import unsafeExceptions.canThrowAny +import JsItEnv.* + +/** Scala.js (Wasm+JSPI) [[CryptoCapability]] integration suite: a thin shell over the shared [[CryptoScenarios]], run + * against the canonical `crypto.dapr.localstorage` store (backed by a fresh RSA key mounted into the sidecar) via the + * live sidecar. The JVM twin [[CryptoItTest]] runs the same scenarios. + * + * Crypto is gRPC-only in the JS SDK (the HTTP client throws `HTTPNotSupportedError`), so this suite exercises the + * lazily created gRPC-protocol client over the real alpha1 streaming wire API. + */ +@scala.caps.assumeSafe +class CryptoJsIntegrationTest extends FunSuite, CryptoScenarios: + + override def munitTimeout: Duration = 120.seconds + + private def run(body: DaprCapability ?=> Unit): Future[Unit] = + js.async(Dapr(clientConfig).run(body)).toFuture + + test("crypto: encryptString then decryptString round-trips the original text")(run(encryptDecryptStringRoundTrip)) + test("crypto: encrypt then decrypt round-trips raw bytes")(run(encryptDecryptBytesRoundTrip)) diff --git a/test/js/integration/InvokeJsIntegrationTest.scala b/test/js/integration/InvokeJsIntegrationTest.scala new file mode 100644 index 0000000..19a6e26 --- /dev/null +++ b/test/js/integration/InvokeJsIntegrationTest.scala @@ -0,0 +1,33 @@ +//> using target.platform "scala-js" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import munit.FunSuite +import scala.concurrent.Future +import scala.concurrent.duration.{Duration, DurationInt} +import scala.scalajs.js +import unsafeExceptions.canThrowAny +import JsItEnv.* + +/** Scala.js (Wasm+JSPI) [[InvokeCapability]] integration suite: a thin shell over the shared [[InvokeScenarios]], run + * against the JS test server's invoke routes ([[JsItServerApp]]) via the live sidecar. The JVM twin [[InvokeItTest]] + * runs the very same scenarios. + * + * The first call retries: daprd reports healthy slightly before the app channel finishes warming up. + */ +@scala.caps.assumeSafe +class InvokeJsIntegrationTest extends FunSuite, InvokeScenarios: + + override def munitTimeout: Duration = 120.seconds + + protected def serverAppId: AppId = ServerAppId + protected def retrying[T](label: String)(body: => T): T = retryUntilSuccess(label)(body) + + private def run(body: DaprCapability ?=> Unit): Future[Unit] = + js.async(Dapr(clientConfig).run(body)).toFuture + + test("invoke: echo roundtrip via the test server")(run(echoRoundtrip)) + test("invoke: falsy body 0 reaches the handler via the raw-fetch fallback")(run(falsyZeroBodyRoundtrips)) + test("invoke: derived EchoService facade calls the matching server routes")(run(derivedEchoServiceFacade)) + test("invoke: invoking a non-existent app throws")(run(nonexistentAppThrows)) diff --git a/test/js/integration/JsItEnv.scala b/test/js/integration/JsItEnv.scala new file mode 100644 index 0000000..770e9bb --- /dev/null +++ b/test/js/integration/JsItEnv.scala @@ -0,0 +1,101 @@ +//> using target.platform "scala-js" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import java.net.URI +import scala.scalajs.js +import scala.util.control.NonFatal +import unsafeExceptions.canThrowAny + +/** Shared constants and polling helpers for the Scala.js integration suites and the [[jsTestServerMain]] test server + * they talk to. + * + * ==Port map (single source of truth on the Scala side)== + * The infra twin of this table lives in `scripts/js-integration-env.sh` — keep the two in sync. All ports are + * non-default to avoid collisions with a locally `dapr init`-ed stack (see the script header for the full map + * including placement/scheduler/metrics). + * + * ==Why polling helpers== + * The JVM integration suites poll for sidecar-startup effects (placement table dissemination, workflow runtime + * registration, pub/sub delivery). The same applies here, but "sleeping" on a single-threaded JS runtime means + * suspending on a timer promise — routed through [[dapr4s.internal.JsAwait]], the one sanctioned home of orphan + * `js.await` (AGENTS.md: never import `allowOrphanJSAwait` anywhere else). These helpers therefore only work on the + * Wasm+JSPI backend, like the capability calls around them. + * + * WHY @assumeSafe: the by-name `body`/`probe` parameters are pure function types under `pureFunctions`, but callers + * pass closures that capture Dapr capabilities from the enclosing `Dapr.run` scope — the standard test-side erasure + * the JVM suites rely on as well (their suite classes are `@assumeSafe` for the same reason). Safe because the + * closures never outlive the `run` block that owns the capabilities. + */ +@scala.caps.assumeSafe +object JsItEnv: + + val DaprHttpPort: Int = 3591 + val DaprGrpcPort: Int = 50191 + val AppPort: Int = 8391 + val ServerAppId: AppId = AppId("js-it-server") + + // Component names match the shared canonical set scripts/it/components/.yaml. + val StateStore: StateStoreName = StateStoreName("statestore") + val PubSub: PubSubName = PubSubName("pubsub") + val LockStore: LockStoreName = LockStoreName("lockstore") + val ConfigStore: ConfigurationStoreName = ConfigurationStoreName("configstore") + val SecretStore: SecretStoreName = SecretStoreName("secretstore") + val CryptoStore: CryptoComponentName = CryptoComponentName("cryptostore") + val CryptoKey: CryptoKeyName = CryptoKeyName("rsa-key") + + /** Client config pointing at the harness sidecar; every suite's `Dapr(...)` uses this. */ + def clientConfig: DaprConfig = DaprConfig( + sidecar = SidecarConfig( + httpEndpoint = URI.create(s"http://localhost:$DaprHttpPort"), + grpcEndpoint = URI.create(s"http://localhost:$DaprGrpcPort"), + ), + ) + + /** Server config for [[jsTestServerMain]]: same sidecar, app server on [[AppPort]]. */ + def serverConfig: DaprConfig = clientConfig.copy(appServer = AppServerConfig(port = DaprPort(AppPort))) + + /** Unique-enough id for test resources. NOT `java.util.UUID.randomUUID()`: that does not '''link''' on Scala.js (it + * reaches for `java.security.SecureRandom`, which the Scala.js javalib does not provide). Test ids need uniqueness + * across a single harness run, not cryptographic strength. + */ + def uniqueId(): String = + s"${System.currentTimeMillis()}-${(js.Math.random() * 1e9).toLong}" + + /** Suspend the current Wasm stack for `ms` milliseconds (the JS analogue of `Thread.sleep`). */ + def sleep(ms: Int): Unit = + dapr4s.internal.JsAwait.await(new js.Promise[Unit]((resolve, _) => { + js.timers.setTimeout(ms.toDouble) { resolve(()); () }: Unit + () + })) + + /** Poll `probe` until it returns `Some`, sleeping `intervalMs` between attempts; fail the test after `timeoutMs`. */ + def eventually[T](label: String, timeoutMs: Int = 30000, intervalMs: Int = 250)(probe: => Option[T]): T = + val deadline = System.currentTimeMillis() + timeoutMs + var result: Option[T] = probe + while result.isEmpty && System.currentTimeMillis() < deadline do + sleep(intervalMs) + result = probe + result.getOrElse(throw new AssertionError(s"eventually($label) timed out after ${timeoutMs}ms")) + + /** Retry `body` until it stops throwing (returning its value), for sidecar-startup races: actor placement table + * dissemination and workflow runtime registration both surface as 500s until ready — exactly what the JVM twins poll + * through (`ActorCapabilityServerTest.waitForCount`, `WorkflowCapabilityServerTest.waitForWorkflowRuntime`). + * Rethrows the last failure after `timeoutMs`. + */ + def retryUntilSuccess[T](label: String, timeoutMs: Int = 60000, intervalMs: Int = 500)(body: => T): T = + val deadline = System.currentTimeMillis() + timeoutMs + var last: Throwable | Null = null + while System.currentTimeMillis() < deadline do + try return body + catch + case NonFatal(e) => + last = e + sleep(intervalMs) + val l = last + throw new AssertionError( + s"retryUntilSuccess($label) still failing after ${timeoutMs}ms: ${ + if l == null then "no attempt ran" else l.toString + }", + ) diff --git a/test/js/integration/JsTestServer.scala b/test/js/integration/JsTestServer.scala new file mode 100644 index 0000000..eefbfd9 --- /dev/null +++ b/test/js/integration/JsTestServer.scala @@ -0,0 +1,79 @@ +//> using target.platform "scala-js" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import dapr4s.test.integration.apps.* +import scala.scalajs.js +import unsafeExceptions.canThrowAny + +/** Workflow that parks on an external event and completes with a value derived from its payload — the raiseEvent + * counterpart of [[dapr4s.test.integration.apps.AddingWorkflow]] (which exercises the activity path). + * `WorkflowJsIntegrationTest` starts it, raises `go`, and asserts on the tripled output (x3 to be distinguishable from + * AddActivities' doubling). + */ +class GatedWorkflow extends Workflow: + def run(using WorkflowContext): Unit = + val gate = WorkflowContext.waitForExternalEvent[IncrRequest](EventName("go")).await() + WorkflowContext.complete(CounterState(gate.amount * 3)) + +/** The inbound handler set the JS integration suites exercise through a real sidecar: + * + * - `echo` / `double` invoke routes — the same method names the derived [[EchoService]] caller facade expects, so + * `InvokeJsIntegrationTest` covers both the plain and the derived invoke path; + * - `echo-int` — Int→Int identity, so a falsy `0` request body (the raw-fetch fallback in the JS client) round-trips + * end to end; + * - `js-it-orders` subscription — writes each event's quantity to state under `js-it-order-{orderId}`, letting + * `PubSubJsIntegrationTest` poll state for delivery; + * - `js-it-zeros` subscription — writes the (falsy) Int payload to the fixed `js-it-zero-marker` key. + */ +object JsItServerApp: + def apply()(using DaprCapability): DaprApp = + DaprCapability.state(JsItEnv.StateStore) { + DaprApp( + invokeRoutes = List( + InvokeRoute[String, String](InvokeMethodName("echo")) { s => + try s + catch case e: Exception => throw e + }, + InvokeRoute[IncrRequest, CounterState](InvokeMethodName("double")) { req => + try CounterState(req.amount * 2) + catch case e: Exception => throw e + }, + InvokeRoute[Int, Int](InvokeMethodName("echo-int")) { i => + try i + catch case e: Exception => throw e + }, + ), + subscriptions = List( + Subscription[OrderEvent](JsItEnv.PubSub, Topic("js-it-orders")) { ev => + try + StateCapability.save(StateStoreKey(s"js-it-order-${ev.data.orderId}"), ev.data.quantity) + SubscriptionResult.Success + catch case e: Exception => throw e + }, + Subscription[Int](JsItEnv.PubSub, Topic("js-it-zeros")) { ev => + try + StateCapability.save(StateStoreKey("js-it-zero-marker"), ev.data) + SubscriptionResult.Success + catch case e: Exception => throw e + }, + ), + ) + } + +/** Entry point of the JS integration test server — the Scala.js twin of the JVM suites' in-test `DaprAppServer` + * threads, but as a separate Node process because `serve` suspends forever (packaged and started by + * `scripts/js-integration-env.sh up`, see the build incantation there). + * + * Hosts [[JsItServerApp]] plus the shared cross-platform fixtures: the `Counter` actor ([[CounterActorApp]]) and the + * `AddingWorkflow` + derived `AddActivities` pair ([[WorkflowApp]]), plus [[GatedWorkflow]] for the raiseEvent path. + * The single `js.async` at the program edge satisfies the Wasm/JSPI requirement documented on [[dapr4s.Dapr]]. + */ +@main def jsTestServerMain(): Unit = + js.async { + println(s"[js-it-server] starting on port ${JsItEnv.AppPort}") + Dapr(JsItEnv.serverConfig).serve { + JsItServerApp() ++ CounterActorApp() ++ WorkflowApp() ++ DaprApp(workflows = List(new GatedWorkflow)) + } + }: Unit diff --git a/test/js/integration/LockJsIntegrationTest.scala b/test/js/integration/LockJsIntegrationTest.scala new file mode 100644 index 0000000..33847e2 --- /dev/null +++ b/test/js/integration/LockJsIntegrationTest.scala @@ -0,0 +1,26 @@ +//> using target.platform "scala-js" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import munit.FunSuite +import scala.concurrent.Future +import scala.concurrent.duration.{Duration, DurationInt} +import scala.scalajs.js +import unsafeExceptions.canThrowAny +import JsItEnv.* + +/** Scala.js (Wasm+JSPI) [[LockCapability]] integration suite: a thin shell over the shared [[LockScenarios]], run + * against the canonical `lock.redis` store via the live sidecar. The JVM twin [[LockItTest]] runs the same scenarios. + */ +@scala.caps.assumeSafe +class LockJsIntegrationTest extends FunSuite, LockScenarios: + + override def munitTimeout: Duration = 120.seconds + + private def run(body: DaprCapability ?=> Unit): Future[Unit] = + js.async(Dapr(clientConfig).run(body)).toFuture + + test("lock: tryLock on a free resource returns true")(run(tryLockFreeReturnsTrue)) + test("lock: tryLock on a held resource returns false")(run(tryLockHeldReturnsFalse)) + test("lock: unlock by the owner returns Success, re-unlock returns LockNotFound")(run(unlockByOwnerThenLockNotFound)) diff --git a/test/js/integration/PubSubJsIntegrationTest.scala b/test/js/integration/PubSubJsIntegrationTest.scala new file mode 100644 index 0000000..cb95312 --- /dev/null +++ b/test/js/integration/PubSubJsIntegrationTest.scala @@ -0,0 +1,75 @@ +//> using target.platform "scala-js" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import dapr4s.test.integration.apps.OrderEvent +import munit.FunSuite +import scala.concurrent.duration.{Duration, DurationInt} +import scala.scalajs.js +import unsafeExceptions.canThrowAny +import JsItEnv.* + +/** [[PublishCapability]] end to end: publish through the sidecar, the JS test server's subscription ([[JsItServerApp]]) + * writes the payload to state, and the test polls state until the write is visible — the Scala.js twin of + * [[PublishCapabilityServerTest]]'s delivery checks. + * + * The falsy-`0` test exercises the raw-fetch fallback in `PublishCapabilityImpl` (the JS SDK silently drops JS-falsy + * request bodies — `if (params?.body)` in HTTPClient.js). + */ +@scala.caps.assumeSafe +class PubSubJsIntegrationTest extends FunSuite: + + override def munitTimeout: Duration = 120.seconds + + test("pubsub: publish is delivered to the server subscription and lands in state"): + js.async { + Dapr(clientConfig).run: + val orderId = uniqueId() + DaprCapability.publish(PubSub) { + PublishCapability.publish(Topic("js-it-orders"), OrderEvent(orderId, "widget", 7)) + } + DaprCapability.state(StateStore) { + val qty = eventually(s"order $orderId visible in state") { + StateCapability.get[Int](StateStoreKey(s"js-it-order-$orderId")) + } + assertEquals(qty, 7) + } + }.toFuture + + test("pubsub: falsy payload 0 goes through the raw-fetch fallback and is delivered intact"): + js.async { + Dapr(clientConfig).run: + val marker = StateStoreKey("js-it-zero-marker") + DaprCapability.state(StateStore) { + StateCapability.delete(marker) + DaprCapability.publish(PubSub) { + PublishCapability.publish(Topic("js-it-zeros"), 0) + } + val received = eventually("zero marker visible in state") { + StateCapability.get[Int](marker) + } + assertEquals(received, 0) + } + }.toFuture + + test("pubsub: bulkPublish delivers every entry"): + js.async { + Dapr(clientConfig).run: + val ids = List(uniqueId(), uniqueId(), uniqueId()) + val entries = ids.zipWithIndex.map { case (id, i) => + BulkPublishEntry(BulkEntryId(s"entry-$i"), OrderEvent(id, "bulk", i + 1)) + } + DaprCapability.publish(PubSub) { + val result = PublishCapability.bulkPublish(Topic("js-it-orders"), entries) + assertEquals(result.failedEntries, Nil) + } + DaprCapability.state(StateStore) { + ids.zipWithIndex.foreach { case (id, i) => + val qty = eventually(s"bulk order $id visible in state") { + StateCapability.get[Int](StateStoreKey(s"js-it-order-$id")) + } + assertEquals(qty, i + 1) + } + } + }.toFuture diff --git a/test/js/integration/SecretsJsIntegrationTest.scala b/test/js/integration/SecretsJsIntegrationTest.scala new file mode 100644 index 0000000..4056d6d --- /dev/null +++ b/test/js/integration/SecretsJsIntegrationTest.scala @@ -0,0 +1,28 @@ +//> using target.platform "scala-js" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import munit.FunSuite +import scala.concurrent.Future +import scala.concurrent.duration.{Duration, DurationInt} +import scala.scalajs.js +import unsafeExceptions.canThrowAny +import JsItEnv.* + +/** Scala.js (Wasm+JSPI) [[SecretsCapability]] integration suite: a thin shell over the shared [[SecretsScenarios]], run + * against the canonical `secretstores.local.file` store (seeded from scripts/it/secrets.json) via the live sidecar. + * The JVM twin [[SecretsItTest]] runs the same scenarios. + */ +@scala.caps.assumeSafe +class SecretsJsIntegrationTest extends FunSuite, SecretsScenarios: + + override def munitTimeout: Duration = 120.seconds + + private def run(body: DaprCapability ?=> Unit): Future[Unit] = + js.async(Dapr(clientConfig).run(body)).toFuture + + test("secrets: get for seeded keys returns Some")(run(getSeededReturnsSome)) + test("secrets: getBulk contains the seeded keys")(run(getBulkContainsSeeded)) + test("secrets: get for a missing key throws (local-file store answers 500)")(run(getMissingKeyThrows)) + test("secrets: get from an unknown store throws")(run(getFromUnknownStoreThrows)) diff --git a/test/js/integration/StateJsIntegrationTest.scala b/test/js/integration/StateJsIntegrationTest.scala new file mode 100644 index 0000000..ad32bcf --- /dev/null +++ b/test/js/integration/StateJsIntegrationTest.scala @@ -0,0 +1,41 @@ +//> using target.platform "scala-js" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import munit.FunSuite +import scala.concurrent.Future +import scala.concurrent.duration.{Duration, DurationInt} +import scala.scalajs.js +import unsafeExceptions.canThrowAny +import JsItEnv.* + +/** Scala.js (Wasm+JSPI) [[StateCapability]] integration suite: a thin shell over the shared [[StateScenarios]] (the + * calls + assertions), run against the canonical `state.redis` component via the live sidecar from + * `scripts/js-integration-env.sh up`. The JVM twin [[StateItTest]] runs the very same scenarios — only the bring-up + * and the `js.async{}.toFuture` boundary differ. + * + * Every munit body is `js.async { ... }.toFuture` — never a raw `js.Promise`, which munit would NOT await (a vacuous + * pass). + */ +@scala.caps.assumeSafe +class StateJsIntegrationTest extends FunSuite, StateScenarios: + + override def munitTimeout: Duration = 120.seconds + + private def run(body: DaprCapability ?=> Unit): Future[Unit] = + js.async(Dapr(clientConfig).run(body)).toFuture + + test("state: save then get returns the saved value")(run(saveThenGet)) + test("state: get for a missing key returns None")(run(getMissingReturnsNone)) + test("state: getWithETag returns value and etag after save")(run(getWithETagAfterSave)) + test("state: getWithETag for a missing key returns none/none")(run(getWithETagMissingReturnsNone)) + test("state: saveWithETag succeeds with the current etag and conflicts with a stale one")( + run(saveWithETagSucceedsThenConflicts), + ) + test("state: delete removes a key")(run(delete)) + test("state: deleteWithETag conflicts on a stale etag then succeeds on the current one")( + run(deleteWithETagConflictThenSucceeds), + ) + test("state: saveBulk persists all entries and getBulk reads them (None for absent)")(run(saveBulkAndGetBulk)) + test("state: transaction upserts and deletes atomically")(run(transactionUpsertsAndDeletes)) diff --git a/test/js/integration/WorkflowJsIntegrationTest.scala b/test/js/integration/WorkflowJsIntegrationTest.scala new file mode 100644 index 0000000..7b95a04 --- /dev/null +++ b/test/js/integration/WorkflowJsIntegrationTest.scala @@ -0,0 +1,69 @@ +//> using target.platform "scala-js" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import dapr4s.test.integration.apps.{AddingWorkflow, CounterState, IncrRequest} +import munit.FunSuite +import scala.concurrent.duration.{Duration, DurationInt} +import scala.scalajs.js +import unsafeExceptions.canThrowAny +import JsItEnv.* + +/** [[WorkflowCapability]] against the workflow runtime hosted by the JS test server ([[dapr4s.internal.WorkflowHost]] + + * the AsyncGenerator coroutine bridge), backed by the harness sidecar's scheduler service — the Scala.js twin of + * [[WorkflowCapabilityServerTest]]. + * + * The first `start` retries until the server's `WorkflowRuntime` has registered with the sidecar (the JVM twin's + * `waitForWorkflowRuntime` poll). [[AddingWorkflow]] doubles its input via the derived activity; [[GatedWorkflow]] + * covers the raiseEvent path (tripling the event payload). + */ +@scala.caps.assumeSafe +class WorkflowJsIntegrationTest extends FunSuite: + + override def munitTimeout: Duration = 120.seconds + + private val addingWorkflow = WorkflowName(classOf[AddingWorkflow].getSimpleName) + private val gatedWorkflow = WorkflowName(classOf[GatedWorkflow].getSimpleName) + + test("workflow: start + waitForCompletion returns the activity-doubled output"): + js.async { + Dapr(clientConfig).run: + DaprCapability.workflow { + val id = retryUntilSuccess("workflow runtime registered") { + WorkflowCapability.start(addingWorkflow, IncrRequest(5)) + } + assert(id.value.nonEmpty, "instanceId should be non-empty") + val snap = WorkflowCapability + .waitForCompletion(id, 60.seconds) + .getOrElse(fail("workflow did not complete within 60s")) + assertEquals(snap.status, WorkflowStatus.Completed) + val output = snap.serializedOutput.getOrElse(fail("completed workflow should have output")) + assertEquals(output.decodeOrThrow[CounterState], CounterState(10)) // AddActivities.add doubles: 5 * 2 + } + }.toFuture + + test("workflow: raiseEvent releases a gated workflow and the payload reaches it"): + js.async { + Dapr(clientConfig).run: + DaprCapability.workflow { + val id = WorkflowInstanceId(s"js-it-gated-${uniqueId()}") + val returned = retryUntilSuccess("workflow runtime registered") { + WorkflowCapability.startWithId(gatedWorkflow, id) + } + assertEquals(returned, id) + // Wait until the instance is parked on the external event before raising it (events raised + // earlier are buffered by the runtime, but asserting Running makes the test deterministic). + val running = eventually(s"gated workflow $id running") { + WorkflowCapability.getStatus(id).filter(_.status == WorkflowStatus.Running) + } + assertEquals(running.status, WorkflowStatus.Running) + WorkflowCapability.raiseEvent(id, EventName("go"), IncrRequest(4)) + val snap = WorkflowCapability + .waitForCompletion(id, 60.seconds) + .getOrElse(fail("gated workflow did not complete within 60s")) + assertEquals(snap.status, WorkflowStatus.Completed) + val output = snap.serializedOutput.getOrElse(fail("completed workflow should have output")) + assertEquals(output.decodeOrThrow[CounterState], CounterState(12)) // GatedWorkflow triples: 4 * 3 + } + }.toFuture diff --git a/test/TestCodecs.scala b/test/jvm/TestCodecs.scala similarity index 95% rename from test/TestCodecs.scala rename to test/jvm/TestCodecs.scala index 52cb153..f87c85d 100644 --- a/test/TestCodecs.scala +++ b/test/jvm/TestCodecs.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s import com.fasterxml.jackson.databind.ObjectMapper @@ -12,6 +13,11 @@ import unsafeExceptions.canThrowAny // // Placed in `package dapr4s` so that `import dapr4s.*` (present in every test // file) brings them into the implicit scope automatically. +// +// WHY @assumeSafe on every given: the anonymous JsonCodec instances must carry an +// empty capture set so safe-mode test code can summon them freely; they close over +// nothing but the shared Jackson ObjectMapper (a pure-by-contract serializer), so +// trusting them is sound. private val testMapper = new ObjectMapper() diff --git a/test/TestDaprExtensions.scala b/test/jvm/TestDaprExtensions.scala similarity index 97% rename from test/TestDaprExtensions.scala rename to test/jvm/TestDaprExtensions.scala index 39d5f9a..7c9763d 100644 --- a/test/TestDaprExtensions.scala +++ b/test/jvm/TestDaprExtensions.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s import java.net.URI diff --git a/test/integration/apps/InventoryServiceMain.scala b/test/jvm/apps/InventoryServiceMain.scala similarity index 97% rename from test/integration/apps/InventoryServiceMain.scala rename to test/jvm/apps/InventoryServiceMain.scala index 136e94b..749462f 100644 --- a/test/integration/apps/InventoryServiceMain.scala +++ b/test/jvm/apps/InventoryServiceMain.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration.apps import dapr4s.* diff --git a/test/integration/apps/OrderServiceMain.scala b/test/jvm/apps/OrderServiceMain.scala similarity index 97% rename from test/integration/apps/OrderServiceMain.scala rename to test/jvm/apps/OrderServiceMain.scala index 28be531..f1cd134 100644 --- a/test/integration/apps/OrderServiceMain.scala +++ b/test/jvm/apps/OrderServiceMain.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration.apps import dapr4s.* diff --git a/test/integration/ActorCapabilityServerTest.scala b/test/jvm/integration/ActorCapabilityServerTest.scala similarity index 97% rename from test/integration/ActorCapabilityServerTest.scala rename to test/jvm/integration/ActorCapabilityServerTest.scala index 745d732..428afc7 100644 --- a/test/integration/ActorCapabilityServerTest.scala +++ b/test/jvm/integration/ActorCapabilityServerTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* @@ -6,11 +7,10 @@ import dapr4s.internal.DaprAppServer import java.net.URI import dapr4s.test.unit.DaprServerTestBase import dapr4s.test.integration.apps.* -import io.dapr.testcontainers.{DaprContainer, Component} +import io.dapr.testcontainers.DaprContainer import com.dimafeng.testcontainers.munit.TestContainersForAll import munit.FunSuite import unsafeExceptions.canThrowAny -import java.util.Collections import java.util.concurrent.atomic.AtomicInteger /** Tests for Counter actor dispatch via [[DaprAppServer]] HTTP, with actor state stored in a real Dapr sidecar. @@ -38,7 +38,7 @@ import java.util.concurrent.atomic.AtomicInteger * Actor IDs are unique per test to prevent state leakage across tests that share the same sidecar container. */ @scala.caps.assumeSafe -class ActorCapabilityServerTest extends FunSuite with TestContainersForAll with DaprServerTestBase: +class ActorCapabilityServerTest extends FunSuite with TestContainersForAll with DaprServerTestBase with RedisFixture: type Containers = DaprTestContainer @@ -84,15 +84,16 @@ class ActorCapabilityServerTest extends FunSuite with TestContainersForAll with // exposeHostPorts above. DaprContainer.configure() would otherwise create a NEW isolated // network; host.testcontainers.internal inside that network points to a different gateway // than the Socat relay — making the app server unreachable from the sidecar. + // Redis on the SHARED network backs the canonical state.redis actor state store (actorStateStore=true), + // matching the JS harness. + val res = startRedis(org.testcontainers.containers.Network.SHARED) val c = DaprTestContainer( DaprContainer(DaprTestContainer.DefaultImage) .withNetwork(org.testcontainers.containers.Network.SHARED) .withAppName("actor-server-test") .withAppPort(appPort) .withAppChannelAddress("host.testcontainers.internal") - .withComponent( - Component("statestore", "state.in-memory", "v1", java.util.Map.of("actorStateStore", "true")), - ), + .withComponent(res.component("statestore")), ) c.start() diff --git a/test/jvm/integration/ConfigurationItTest.scala b/test/jvm/integration/ConfigurationItTest.scala new file mode 100644 index 0000000..e98989e --- /dev/null +++ b/test/jvm/integration/ConfigurationItTest.scala @@ -0,0 +1,17 @@ +//> using target.platform "jvm" +package dapr4s.test.integration + +import dapr4s.given +import munit.FunSuite +import unsafeExceptions.canThrowAny + +/** JVM [[dapr4s.ConfigurationCapability]] integration suite: a thin shell over the shared [[ConfigurationScenarios]] + * and [[SharedDaprItSuite]] (the canonical `configuration.redis` store). The JS twin + * [[ConfigurationJsIntegrationTest]] runs the very same scenarios. Replaces the former + * ConfigurationCapabilityServerTest. + */ +@scala.caps.assumeSafe +class ConfigurationItTest extends FunSuite, SharedDaprItSuite, ConfigurationScenarios: + + test("configuration: get returns the seeded items with values and versions")(withDapr(getReturnsSeededItems)) + test("configuration: get for an unknown key returns no item for it")(withDapr(getUnknownKeyReturnsNoItem)) diff --git a/test/integration/ConversationCapabilityServerTest.scala b/test/jvm/integration/ConversationCapabilityServerTest.scala similarity index 97% rename from test/integration/ConversationCapabilityServerTest.scala rename to test/jvm/integration/ConversationCapabilityServerTest.scala index 46468df..216e80a 100644 --- a/test/integration/ConversationCapabilityServerTest.scala +++ b/test/jvm/integration/ConversationCapabilityServerTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/jvm/integration/CryptoItTest.scala b/test/jvm/integration/CryptoItTest.scala new file mode 100644 index 0000000..ffd3b90 --- /dev/null +++ b/test/jvm/integration/CryptoItTest.scala @@ -0,0 +1,18 @@ +//> using target.platform "jvm" +package dapr4s.test.integration + +import dapr4s.given +import munit.FunSuite +import unsafeExceptions.canThrowAny + +/** JVM [[dapr4s.CryptoCapability]] integration suite: a thin shell over the shared [[CryptoScenarios]] and + * [[SharedDaprItSuite]] (the canonical `crypto.dapr.localstorage` store backed by a fresh RSA key). The JS twin + * [[CryptoJsIntegrationTest]] runs the very same scenarios. Replaces the former CryptoCapabilityServerTest. + */ +@scala.caps.assumeSafe +class CryptoItTest extends FunSuite, SharedDaprItSuite, CryptoScenarios: + + test("crypto: encryptString then decryptString round-trips the original text")( + withDapr(encryptDecryptStringRoundTrip), + ) + test("crypto: encrypt then decrypt round-trips raw bytes")(withDapr(encryptDecryptBytesRoundTrip)) diff --git a/test/integration/DaprTestContainer.scala b/test/jvm/integration/DaprTestContainer.scala similarity index 97% rename from test/integration/DaprTestContainer.scala rename to test/jvm/integration/DaprTestContainer.scala index c8be979..c43df00 100644 --- a/test/integration/DaprTestContainer.scala +++ b/test/jvm/integration/DaprTestContainer.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import com.dimafeng.testcontainers.SingleContainer diff --git a/test/integration/EndToEndIntegrationTest.scala b/test/jvm/integration/EndToEndIntegrationTest.scala similarity index 95% rename from test/integration/EndToEndIntegrationTest.scala rename to test/jvm/integration/EndToEndIntegrationTest.scala index 895a5e8..2cfea56 100644 --- a/test/integration/EndToEndIntegrationTest.scala +++ b/test/jvm/integration/EndToEndIntegrationTest.scala @@ -1,10 +1,11 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* import dapr4s.given import dapr4s.test.integration.apps.* import dapr4s.test.unit.DaprServerTestBase -import io.dapr.testcontainers.{DaprContainer, Component} +import io.dapr.testcontainers.DaprContainer import com.dimafeng.testcontainers.GenericContainer import com.dimafeng.testcontainers.lifecycle.and import com.dimafeng.testcontainers.lifecycle.Andable.AndableOps @@ -13,8 +14,6 @@ import org.testcontainers.containers.Network import org.testcontainers.containers.wait.strategy.Wait import munit.FunSuite -import java.util.Collections - /** End-to-end integration test that runs both [[OrderServiceApp]] and [[InventoryServiceApp]] against the same real * Dapr sidecar, exercising the full order-placement → inventory-update flow. * @@ -42,17 +41,18 @@ class EndToEndIntegrationTest extends FunSuite with TestContainersForAll with Da waitStrategy = Wait.forLogMessage(".*Ready to accept connections.*", 1), ) redis.container.withNetwork(network) - redis.container.withNetworkAliases("redis") + redis.container.withNetworkAliases(JvmItComponents.RedisAlias) redis.start() + val res = JvmItComponents.render() val c = DaprTestContainer( DaprContainer(DaprTestContainer.DefaultImage) .withNetwork(network) .withAppName("e2e-test") .withAppPort(0) - .withComponent(Component("statestore", "state.in-memory", "v1", Collections.emptyMap())) - .withComponent(Component("pubsub", "pubsub.in-memory", "v1", Collections.emptyMap())) - .withComponent(Component("lockstore", "lock.redis", "v1", java.util.Map.of("redisHost", "redis:6379"))) + .withComponent(res.component("statestore")) + .withComponent(res.component("pubsub")) + .withComponent(res.component("lockstore")) .dependsOn(redis.container), ) c.start() diff --git a/test/integration/InventoryServiceIntegrationTest.scala b/test/jvm/integration/InventoryServiceIntegrationTest.scala similarity index 93% rename from test/integration/InventoryServiceIntegrationTest.scala rename to test/jvm/integration/InventoryServiceIntegrationTest.scala index e336c25..9e585e1 100644 --- a/test/integration/InventoryServiceIntegrationTest.scala +++ b/test/jvm/integration/InventoryServiceIntegrationTest.scala @@ -1,10 +1,11 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* import dapr4s.given import dapr4s.test.integration.apps.* import dapr4s.test.unit.DaprServerTestBase -import io.dapr.testcontainers.{DaprContainer, Component} +import io.dapr.testcontainers.DaprContainer import com.dimafeng.testcontainers.GenericContainer import com.dimafeng.testcontainers.lifecycle.and import com.dimafeng.testcontainers.lifecycle.Andable.AndableOps @@ -13,8 +14,6 @@ import org.testcontainers.containers.Network import org.testcontainers.containers.wait.strategy.Wait import munit.FunSuite -import java.util.Collections - /** Integration tests for [[InventoryServiceApp]] against a real Dapr sidecar, dispatched through a real * [[dapr4s.internal.DaprAppServer]] HTTP server. * @@ -39,17 +38,18 @@ class InventoryServiceIntegrationTest extends FunSuite with TestContainersForAll waitStrategy = Wait.forLogMessage(".*Ready to accept connections.*", 1), ) redis.container.withNetwork(network) - redis.container.withNetworkAliases("redis") + redis.container.withNetworkAliases(JvmItComponents.RedisAlias) redis.start() + val res = JvmItComponents.render() val c = DaprTestContainer( DaprContainer(DaprTestContainer.DefaultImage) .withNetwork(network) .withAppName("inventory-service-test") .withAppPort(0) - .withComponent(Component("statestore", "state.in-memory", "v1", Collections.emptyMap())) - .withComponent(Component("pubsub", "pubsub.in-memory", "v1", Collections.emptyMap())) - .withComponent(Component("lockstore", "lock.redis", "v1", java.util.Map.of("redisHost", "redis:6379"))) + .withComponent(res.component("statestore")) + .withComponent(res.component("pubsub")) + .withComponent(res.component("lockstore")) .dependsOn(redis.container), ) c.start() diff --git a/test/jvm/integration/InvokeItTest.scala b/test/jvm/integration/InvokeItTest.scala new file mode 100644 index 0000000..8b433f0 --- /dev/null +++ b/test/jvm/integration/InvokeItTest.scala @@ -0,0 +1,95 @@ +//> using target.platform "jvm" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import dapr4s.internal.DaprAppServer +import dapr4s.test.unit.DaprServerTestBase +import dapr4s.test.integration.apps.* +import io.dapr.testcontainers.DaprContainer +import com.dimafeng.testcontainers.munit.TestContainersForAll +import munit.FunSuite +import unsafeExceptions.canThrowAny + +/** JVM [[InvokeCapability]] integration suite: a thin shell over the shared [[InvokeScenarios]] (caller side: echo, + * falsy-0, the derived [[EchoService]] facade and the non-existent-app error path). The JS twin + * [[InvokeJsIntegrationTest]] runs the very same scenarios. + * + * Service invocation needs a reachable target, so — unlike the direct-call [[SharedDaprItSuite]] suites — this owns a + * two-phase bring-up: a host-side [[DaprAppServer]] (registering the echo / echo-int / double routes the scenarios + * call) is started and exposed to Docker BEFORE the sidecar, which is then pointed back at it (the same pattern as + * [[ActorCapabilityServerTest]]). Replaces the former InvokeCapabilityServerTest + InvokeIntegrationTest. + */ +@scala.caps.assumeSafe +class InvokeItTest extends FunSuite, TestContainersForAll, DaprServerTestBase, InvokeScenarios: + + override type Containers = DaprTestContainer + + protected def serverAppId: AppId = AppId("svc-invoke-test") + // The sidecar health is polled up front (waitForSidecarHealth), so no per-call retry is needed. + protected def retrying[T](label: String)(body: => T): T = body + + private val appPort: Int = + val s = java.net.ServerSocket(0) + val p = s.getLocalPort + s.close() + p + + private var appServerThread: Option[Thread] = None + + override def afterAll(): Unit = + super.afterAll() + appServerThread.foreach { t => t.interrupt(); t.join(2000) } + + // The routes the shared InvokeScenarios exercise — the same set JsItServerApp registers on JS. + private val echoApp = DaprApp( + invokeRoutes = List( + InvokeRoute[String, String](InvokeMethodName("echo")) { s => s }, + InvokeRoute[Int, Int](InvokeMethodName("echo-int")) { i => i }, + InvokeRoute[IncrRequest, CounterState](InvokeMethodName("double")) { req => CounterState(req.amount * 2) }, + ), + ) + + override def startContainers(): DaprTestContainer = + org.testcontainers.Testcontainers.exposeHostPorts(appPort) + val server = new DaprAppServer(echoApp) + appServerThread = Some( + Thread.ofVirtual().start(() => server.startAndBlock(appPort, TestDapr.placeholderCapability)), + ) + waitForPort(appPort, 5000) + + val c = DaprTestContainer( + DaprContainer(DaprTestContainer.DefaultImage) + .withNetwork(org.testcontainers.containers.Network.SHARED) + .withAppName("svc-invoke-test") + .withAppPort(appPort) + .withAppChannelAddress("host.testcontainers.internal"), + ) + c.start() + waitForSidecarHealth(c.httpEndpoint.getPort) + c + + private def waitForSidecarHealth(sidecarPort: Int, maxMs: Int = 30000): Unit = + val url = s"http://localhost:$sidecarPort/v1.0/healthz" + val deadline = System.currentTimeMillis() + maxMs + while System.currentTimeMillis() < deadline do + try + val conn = java.net.URI.create(url).toURL.nn.openConnection().asInstanceOf[java.net.HttpURLConnection] + conn.setRequestMethod("GET") + conn.setConnectTimeout(1000) + conn.setReadTimeout(2000) + conn.connect() + val code = conn.getResponseCode + conn.disconnect() + if code == 204 then return + catch case _: Exception => () + Thread.sleep(200) + throw RuntimeException(s"Sidecar not healthy after ${maxMs}ms") + + private def withDapr(body: DaprCapability ?=> Unit): Unit = + withContainers { c => Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint)(body) } + + test("invoke: echo roundtrip via the app server")(withDapr(echoRoundtrip)) + test("invoke: falsy body 0 reaches the handler")(withDapr(falsyZeroBodyRoundtrips)) + test("invoke: derived EchoService facade calls the matching server routes")(withDapr(derivedEchoServiceFacade)) + test("invoke: invoking a non-existent app throws")(withDapr(nonexistentAppThrows)) diff --git a/test/integration/JobsCapabilityServerTest.scala b/test/jvm/integration/JobsCapabilityServerTest.scala similarity index 99% rename from test/integration/JobsCapabilityServerTest.scala rename to test/jvm/integration/JobsCapabilityServerTest.scala index f2dc6e6..9dfc506 100644 --- a/test/integration/JobsCapabilityServerTest.scala +++ b/test/jvm/integration/JobsCapabilityServerTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/jvm/integration/JvmItComponents.scala b/test/jvm/integration/JvmItComponents.scala new file mode 100644 index 0000000..5d017ca --- /dev/null +++ b/test/jvm/integration/JvmItComponents.scala @@ -0,0 +1,86 @@ +//> using target.platform "jvm" +package dapr4s.test.integration + +import java.nio.file.{Files, Path, Paths} +import java.security.KeyPairGenerator +import java.util.Base64 + +/** Renders the CANONICAL shared Dapr component set (scripts/it/components/.yaml) for the JVM testcontainers + * topology into a fresh temp dir, alongside the shared secrets.json and a freshly generated RSA key — the exact same + * inputs the JS harness assembles (scripts/js-integration-env.sh). + * + * The YAML files are the single source of truth; only `${DAPR4S_IT_REDIS_HOST}` is environment-specific. Here it is + * substituted with `redis:6379` (the redis testcontainer's network alias on the shared Docker network) — mirroring + * scripts/it/render-components.sh, which the JS host-network harness uses with `localhost:6391`. + * + * The rendered tree is then fed to a daprd container: + * - component YAMLs via `DaprContainer.withComponent(Path)`, + * - the `keys/` dir and `secrets.json` mounted at `/dapr4s-it` (the in-container paths the cryptostore/secretstore + * manifests reference). + */ +object JvmItComponents: + + /** Network alias of the redis testcontainer; the rendered redisHost is `redis:6379`. */ + val RedisAlias = "redis" + val RedisHostValue = s"$RedisAlias:6379" + + private val Placeholder = "${DAPR4S_IT_REDIS_HOST}" + + /** Canonical component manifest file names (= scripts/it/components/.yaml). */ + val ComponentFileNames: List[String] = + List("statestore", "pubsub", "lockstore", "configstore", "cryptostore", "secretstore").map(_ + ".yaml") + + /** Configuration items both harnesses seed into redis (`value||version`). */ + val SeededConfig: List[(String, String)] = + List("dapr4s-it-cfg-a" -> "alpha||v1", "dapr4s-it-cfg-b" -> "beta||v2") + + /** A rendered resource tree: the temp root, the rendered component file Paths keyed by component name (e.g. + * "statestore"), the keys dir and the secrets.json file (ready to mount into daprd). + */ + final case class Rendered(root: Path, components: Map[String, Path], keysDir: Path, secretsFile: Path): + /** The rendered manifest Path for one component, e.g. `component("statestore")`. */ + def component(name: String): Path = + components.getOrElse( + name, + throw IllegalArgumentException(s"no shared component '$name'; have ${components.keySet}"), + ) + + /** Render the shared set for `redisHost` (default `redis:6379`) into a fresh temp dir. */ + def render(redisHost: String = RedisHostValue): Rendered = + val srcDir = sharedComponentsDir() + val root = Files.createTempDirectory("dapr4s-it").nn + val compDir = Files.createDirectories(root.resolve("components")).nn + val components = ComponentFileNames.map { name => + val rendered = Files.readString(srcDir.resolve(name)).nn.replace(Placeholder, redisHost) + val out = compDir.resolve(name) + Files.writeString(out, rendered) + name.stripSuffix(".yaml") -> out + }.toMap + // Shared secrets.json (the secretstore manifest points at /dapr4s-it/secrets.json). + val secretsFile = root.resolve("secrets.json") + Files.copy(repoRoot().resolve("scripts/it/secrets.json"), secretsFile) + // Fresh RSA key for crypto.dapr.localstorage (the cryptostore manifest points at /dapr4s-it/keys). + val keysDir = Files.createDirectories(root.resolve("keys")).nn + Files.writeString(keysDir.resolve("rsa-key"), generateRsaPrivateKeyPem()) + Rendered(root, components, keysDir, secretsFile) + + private def generateRsaPrivateKeyPem(): String = + val kpg = KeyPairGenerator.getInstance("RSA").nn + kpg.initialize(2048) + val der = kpg.generateKeyPair().nn.getPrivate.nn.getEncoded.nn + val b64 = Base64.getMimeEncoder(64, Array[Byte]('\n')).nn.encodeToString(der) + s"-----BEGIN PRIVATE KEY-----\n$b64\n-----END PRIVATE KEY-----\n" + + private def sharedComponentsDir(): Path = + val d = repoRoot().resolve("scripts/it/components") + require(Files.isDirectory(d), s"shared component dir not found: $d (cwd=${Paths.get("").toAbsolutePath})") + d + + /** Locate the repo root (the dir holding `project.scala`) by walking up from the working dir — robust to scala-cli + * running tests from a nested working directory. + */ + private def repoRoot(): Path = + var p: Path | Null = Paths.get("").toAbsolutePath + while p != null && !Files.exists(p.resolve("project.scala")) do p = p.getParent + require(p != null, "could not locate repo root (no project.scala found walking up from cwd)") + p.nn diff --git a/test/jvm/integration/LockItTest.scala b/test/jvm/integration/LockItTest.scala new file mode 100644 index 0000000..9dd3df6 --- /dev/null +++ b/test/jvm/integration/LockItTest.scala @@ -0,0 +1,19 @@ +//> using target.platform "jvm" +package dapr4s.test.integration + +import dapr4s.given +import munit.FunSuite +import unsafeExceptions.canThrowAny + +/** JVM [[dapr4s.LockCapability]] integration suite: a thin shell over the shared [[LockScenarios]] and + * [[SharedDaprItSuite]] (the canonical `lock.redis` store). The JS twin [[LockJsIntegrationTest]] runs the very same + * scenarios. Replaces the former LockCapabilityServerTest (server-routed). + */ +@scala.caps.assumeSafe +class LockItTest extends FunSuite, SharedDaprItSuite, LockScenarios: + + test("lock: tryLock on a free resource returns true")(withDapr(tryLockFreeReturnsTrue)) + test("lock: tryLock on a held resource returns false")(withDapr(tryLockHeldReturnsFalse)) + test("lock: unlock by the owner returns Success, re-unlock returns LockNotFound")( + withDapr(unlockByOwnerThenLockNotFound), + ) diff --git a/test/integration/OrderServiceIntegrationTest.scala b/test/jvm/integration/OrderServiceIntegrationTest.scala similarity index 90% rename from test/integration/OrderServiceIntegrationTest.scala rename to test/jvm/integration/OrderServiceIntegrationTest.scala index c7fadcc..ba804fe 100644 --- a/test/integration/OrderServiceIntegrationTest.scala +++ b/test/jvm/integration/OrderServiceIntegrationTest.scala @@ -1,33 +1,36 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* import dapr4s.given import dapr4s.test.integration.apps.* import dapr4s.test.unit.DaprServerTestBase -import io.dapr.testcontainers.{DaprContainer, Component} -import com.dimafeng.testcontainers.munit.TestContainersForAll +import io.dapr.testcontainers.DaprContainer import munit.FunSuite - -import java.util.Collections +import org.testcontainers.containers.Network /** Integration tests for [[OrderServiceApp]] against a real Dapr sidecar, dispatched through a real * [[dapr4s.internal.DaprAppServer]] HTTP server. * * Each test starts a real HTTP server wrapping the handler app and exercises it via HTTP POST — the full encode → HTTP - * → decode path is tested, with state persisted in the real in-memory Dapr sidecar. + * → decode path is tested, with state persisted in the real `state.redis` Dapr sidecar (the shared + * scripts/it/components set — see [[RedisFixture]]). */ @scala.caps.assumeSafe -class OrderServiceIntegrationTest extends FunSuite with TestContainersForAll with DaprServerTestBase: +class OrderServiceIntegrationTest extends FunSuite, RedisFixture, DaprServerTestBase: - type Containers = DaprTestContainer + override type Containers = DaprTestContainer override def startContainers(): DaprTestContainer = + val network = Network.newNetwork() + val res = startRedis(network) val c = DaprTestContainer( DaprContainer(DaprTestContainer.DefaultImage) + .withNetwork(network) .withAppName("order-service-test") .withAppPort(0) - .withComponent(Component("statestore", "state.in-memory", "v1", Collections.emptyMap())) - .withComponent(Component("pubsub", "pubsub.in-memory", "v1", Collections.emptyMap())), + .withComponent(res.component("statestore")) + .withComponent(res.component("pubsub")), ) c.start() c diff --git a/test/integration/PubSubIntegrationTest.scala b/test/jvm/integration/PubSubIntegrationTest.scala similarity index 71% rename from test/integration/PubSubIntegrationTest.scala rename to test/jvm/integration/PubSubIntegrationTest.scala index 7ef80af..07efd3b 100644 --- a/test/integration/PubSubIntegrationTest.scala +++ b/test/jvm/integration/PubSubIntegrationTest.scala @@ -1,32 +1,30 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* import dapr4s.given -import io.dapr.testcontainers.{DaprContainer, Component} -import com.dimafeng.testcontainers.munit.TestContainersForAll +import io.dapr.testcontainers.DaprContainer import munit.FunSuite +import org.testcontainers.containers.Network -import java.util.Collections - -/** Integration tests for [[PublishCapability]] using a real DAPR sidecar in Docker via Testcontainers. +/** Integration tests for [[PublishCapability]] using a real DAPR sidecar in Docker via Testcontainers, publishing to + * the canonical `pubsub.redis` component (the shared scripts/it/components set, matching the JS harness — see + * [[RedisFixture]]). */ @scala.caps.assumeSafe -class PubSubIntegrationTest extends FunSuite with TestContainersForAll: +class PubSubIntegrationTest extends FunSuite, RedisFixture: - type Containers = DaprTestContainer + override type Containers = DaprTestContainer override def startContainers(): DaprTestContainer = - val component = Component( - "pubsub", - "pubsub.in-memory", - "v1", - Collections.emptyMap[String, String](), - ) + val network = Network.newNetwork() + val res = startRedis(network) val c = DaprTestContainer( DaprContainer(DaprTestContainer.DefaultImage) + .withNetwork(network) .withAppName("pubsub-test-app") .withAppPort(0) - .withComponent(component), + .withComponent(res.component("pubsub")), ) c.start() c diff --git a/test/integration/PublishCapabilityServerTest.scala b/test/jvm/integration/PublishCapabilityServerTest.scala similarity index 92% rename from test/integration/PublishCapabilityServerTest.scala rename to test/jvm/integration/PublishCapabilityServerTest.scala index ebf63d6..c0718b5 100644 --- a/test/integration/PublishCapabilityServerTest.scala +++ b/test/jvm/integration/PublishCapabilityServerTest.scala @@ -1,34 +1,37 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* import dapr4s.given import dapr4s.test.unit.DaprServerTestBase -import io.dapr.testcontainers.{DaprContainer, Component} -import com.dimafeng.testcontainers.munit.TestContainersForAll +import io.dapr.testcontainers.DaprContainer import munit.FunSuite +import org.testcontainers.containers.Network import unsafeExceptions.canThrowAny -import java.util.Collections - /** Tests for every [[PublishCapability]] method through real [[dapr4s.internal.DaprAppServer]] HTTP dispatch, backed by - * real Dapr pub/sub and state-store components via Testcontainers. + * the canonical `pubsub.redis` + `state.redis` components (the shared scripts/it/components set, matching the JS + * harness — see [[RedisFixture]]). * * Publish operations fire at the real Dapr sidecar and verify the handler returns without error. Subscription dispatch * is exercised by POSTing a CloudEvent JSON envelope directly to the subscription route — the same format Dapr uses in * production. */ @scala.caps.assumeSafe -class PublishCapabilityServerTest extends FunSuite with TestContainersForAll with DaprServerTestBase: +class PublishCapabilityServerTest extends FunSuite, RedisFixture, DaprServerTestBase: - type Containers = DaprTestContainer + override type Containers = DaprTestContainer override def startContainers(): DaprTestContainer = + val network = Network.newNetwork() + val res = startRedis(network) val c = DaprTestContainer( DaprContainer(DaprTestContainer.DefaultImage) + .withNetwork(network) .withAppName("pubsub-server-test") .withAppPort(0) - .withComponent(Component("pubsub", "pubsub.in-memory", "v1", Collections.emptyMap())) - .withComponent(Component("statestore", "state.in-memory", "v1", Collections.emptyMap())), + .withComponent(res.component("pubsub")) + .withComponent(res.component("statestore")), ) c.start() c diff --git a/test/jvm/integration/RedisFixture.scala b/test/jvm/integration/RedisFixture.scala new file mode 100644 index 0000000..6d5ad25 --- /dev/null +++ b/test/jvm/integration/RedisFixture.scala @@ -0,0 +1,43 @@ +//> using target.platform "jvm" +package dapr4s.test.integration + +import com.dimafeng.testcontainers.GenericContainer +import com.dimafeng.testcontainers.munit.TestContainersForAll +import munit.FunSuite +import org.testcontainers.containers.Network +import org.testcontainers.containers.wait.strategy.Wait + +/** Mixin for the server-delivery integration suites whose bespoke bring-up (a host [[dapr4s.internal.DaprAppServer]] + * the sidecar must reach, two-phase actor/workflow startup, multiple in-test servers) cannot use + * [[SharedDaprItSuite]], but which still need their Dapr components on a real Redis (matching the JS harness — "redis + * everywhere"). + * + * It owns a Redis container (managed outside testcontainers-scala's `Containers`, so suites keep their existing + * `Containers` type and unchanged test bodies) and renders the canonical shared component set (scripts/it/components) + * so suites feed daprd the SAME manifests the JS harness and [[SharedDaprItSuite]] use, via + * `rendered.component("statestore")` etc. + */ +trait RedisFixture extends TestContainersForAll: + self: FunSuite => + + private var redis: GenericContainer | Null = null + + /** Start Redis (alias `redis`, the rendered redisHost) on `network` and render the shared components. Call from + * `startContainers`; pass `Network.SHARED` for the two-phase host-server suites, `Network.newNetwork()` otherwise. + */ + protected def startRedis(network: Network): JvmItComponents.Rendered = + val r = GenericContainer( + dockerImage = "redis:7-alpine", + exposedPorts = Seq(6379), + waitStrategy = Wait.forLogMessage(".*Ready to accept connections.*", 1), + ) + r.container.withNetwork(network) + r.container.withNetworkAliases(JvmItComponents.RedisAlias) + r.start() + redis = r + JvmItComponents.render() + + override def afterAll(): Unit = + super.afterAll() + val r = redis + if r != null then r.stop() diff --git a/test/jvm/integration/SecretsItTest.scala b/test/jvm/integration/SecretsItTest.scala new file mode 100644 index 0000000..ffa8a95 --- /dev/null +++ b/test/jvm/integration/SecretsItTest.scala @@ -0,0 +1,19 @@ +//> using target.platform "jvm" +package dapr4s.test.integration + +import dapr4s.given +import munit.FunSuite +import unsafeExceptions.canThrowAny + +/** JVM [[dapr4s.SecretsCapability]] integration suite: a thin shell over the shared [[SecretsScenarios]] and + * [[SharedDaprItSuite]] (the canonical `secretstores.local.file` store). The JS twin [[SecretsJsIntegrationTest]] runs + * the very same scenarios. Replaces the former SecretsCapabilityServerTest (local.env, server-routed) + + * SecretsIntegrationTest. + */ +@scala.caps.assumeSafe +class SecretsItTest extends FunSuite, SharedDaprItSuite, SecretsScenarios: + + test("secrets: get for seeded keys returns Some")(withDapr(getSeededReturnsSome)) + test("secrets: getBulk contains the seeded keys")(withDapr(getBulkContainsSeeded)) + test("secrets: get for a missing key throws (local-file store answers 500)")(withDapr(getMissingKeyThrows)) + test("secrets: get from an unknown store throws")(withDapr(getFromUnknownStoreThrows)) diff --git a/test/jvm/integration/SharedDaprItSuite.scala b/test/jvm/integration/SharedDaprItSuite.scala new file mode 100644 index 0000000..811a63f --- /dev/null +++ b/test/jvm/integration/SharedDaprItSuite.scala @@ -0,0 +1,72 @@ +//> using target.platform "jvm" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import com.dimafeng.testcontainers.GenericContainer +import com.dimafeng.testcontainers.lifecycle.and +import com.dimafeng.testcontainers.lifecycle.Andable.AndableOps +import com.dimafeng.testcontainers.munit.TestContainersForAll +import io.dapr.testcontainers.DaprContainer +import org.testcontainers.containers.Network +import org.testcontainers.containers.wait.strategy.Wait +import org.testcontainers.utility.MountableFile +import munit.FunSuite + +/** JVM integration fixture that mirrors the JS (Wasm+JSPI) harness's single all-components sidecar: one daprd backed by + * a real Redis, loading the CANONICAL shared component set (scripts/it/components/.yaml) via + * [[DaprContainer.withComponent(Path)]], with the shared `secrets.json` and a fresh RSA key mounted at `/dapr4s-it`. + * + * Direct-call capability suites mix this in and call the shared scenario traits in `test/shared/scenarios`, so the JVM + * and JS suites exercise the exact same calls and assertions against the exact same component definitions — only + * bring-up (testcontainers here, external Docker+Node there) and the munit boundary (synchronous here, `Future` there) + * differ. + */ +trait SharedDaprItSuite extends TestContainersForAll: + self: FunSuite => + + override type Containers = GenericContainer and DaprTestContainer + + // Canonical component names live once in ItNames (= scripts/it/components/.yaml). + + override def startContainers(): GenericContainer and DaprTestContainer = + val network = Network.newNetwork() + + val redis = GenericContainer( + dockerImage = "redis:7-alpine", + exposedPorts = Seq(6379), + waitStrategy = Wait.forLogMessage(".*Ready to accept connections.*", 1), + ) + redis.container.withNetwork(network) + redis.container.withNetworkAliases(JvmItComponents.RedisAlias) + redis.start() + + // Seed configuration items before daprd reads them (redis config store splits "value||version"). + val args = Array("redis-cli", "MSET") ++ JvmItComponents.SeededConfig.flatMap((k, v) => List(k, v)) + val seed = redis.container.execInContainer(args*) + assertEquals(seed.getExitCode, 0, s"redis MSET failed: ${seed.getStderr}") + + val res = JvmItComponents.render() + + var dc = DaprContainer(DaprTestContainer.DefaultImage) + .withNetwork(network) + .withAppName("shared-it") + .withAppPort(0) + // 0x1ed = 0755 (keys dir + key), 0x1a4 = 0644 (secrets file): daprd runs as non-root and + // otherwise fails the components with "permission denied". + .withCopyFileToContainer(MountableFile.forHostPath(res.keysDir, 0x1ed), "/dapr4s-it/keys") + .withCopyFileToContainer(MountableFile.forHostPath(res.secretsFile, 0x1a4), "/dapr4s-it/secrets.json") + .dependsOn(redis.container) + for p <- res.components.values do dc = dc.withComponent(p) + + val c = DaprTestContainer(dc) + c.start() + redis and c + + /** Run `body` against the started sidecar with a [[DaprCapability]] in scope — the JVM analogue of the JS suites' + * `Dapr(clientConfig).run { ... }`. + */ + protected def withDapr(body: DaprCapability ?=> Unit): Unit = + withContainers { case _ and c => + Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint)(body) + } diff --git a/test/jvm/integration/StateItTest.scala b/test/jvm/integration/StateItTest.scala new file mode 100644 index 0000000..9850cf3 --- /dev/null +++ b/test/jvm/integration/StateItTest.scala @@ -0,0 +1,31 @@ +//> using target.platform "jvm" +package dapr4s.test.integration + +import dapr4s.given +import munit.FunSuite +import unsafeExceptions.canThrowAny + +/** JVM [[dapr4s.StateCapability]] integration suite: a thin shell over the shared [[StateScenarios]] (the calls + + * assertions) and [[SharedDaprItSuite]] (the testcontainers sidecar on the canonical `state.redis` component). The JS + * twin [[StateJsIntegrationTest]] runs the very same scenarios. + * + * Replaces the former StateCapabilityServerTest (server-routed) + StateIntegrationTest (direct): route dispatch is + * covered by the unit ServerRouteDerivationTest; this exercises the capability directly against a real sidecar, + * identically to JS. + */ +@scala.caps.assumeSafe +class StateItTest extends FunSuite, SharedDaprItSuite, StateScenarios: + + test("state: save then get returns the saved value")(withDapr(saveThenGet)) + test("state: get for a missing key returns None")(withDapr(getMissingReturnsNone)) + test("state: getWithETag returns value and etag after save")(withDapr(getWithETagAfterSave)) + test("state: getWithETag for a missing key returns none/none")(withDapr(getWithETagMissingReturnsNone)) + test("state: saveWithETag succeeds with the current etag and conflicts with a stale one")( + withDapr(saveWithETagSucceedsThenConflicts), + ) + test("state: delete removes a key")(withDapr(delete)) + test("state: deleteWithETag conflicts on a stale etag then succeeds on the current one")( + withDapr(deleteWithETagConflictThenSucceeds), + ) + test("state: saveBulk persists all entries and getBulk reads them (None for absent)")(withDapr(saveBulkAndGetBulk)) + test("state: transaction upserts and deletes atomically")(withDapr(transactionUpsertsAndDeletes)) diff --git a/test/integration/TestDaprApp.scala b/test/jvm/integration/TestDaprApp.scala similarity index 99% rename from test/integration/TestDaprApp.scala rename to test/jvm/integration/TestDaprApp.scala index 405b3da..92ebbe0 100644 --- a/test/integration/TestDaprApp.scala +++ b/test/jvm/integration/TestDaprApp.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/integration/WorkflowCapabilityServerTest.scala b/test/jvm/integration/WorkflowCapabilityServerTest.scala similarity index 96% rename from test/integration/WorkflowCapabilityServerTest.scala rename to test/jvm/integration/WorkflowCapabilityServerTest.scala index 7699e0e..1088bcb 100644 --- a/test/integration/WorkflowCapabilityServerTest.scala +++ b/test/jvm/integration/WorkflowCapabilityServerTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* @@ -5,11 +6,10 @@ import dapr4s.given import dapr4s.internal.DaprAppServer import dapr4s.test.unit.DaprServerTestBase import dapr4s.test.integration.apps.* -import io.dapr.testcontainers.{DaprContainer, Component} +import io.dapr.testcontainers.DaprContainer import com.dimafeng.testcontainers.munit.TestContainersForAll import munit.FunSuite import unsafeExceptions.canThrowAny -import java.util.Collections import scala.concurrent.duration.* /** Integration tests for [[WorkflowCapability]] backed by a real Dapr sidecar and workflow runtime. @@ -24,7 +24,7 @@ import scala.concurrent.duration.* * activity doubles its input, so starting the workflow with `IncrRequest(5)` should produce `CounterState(10)`. */ @scala.caps.assumeSafe -class WorkflowCapabilityServerTest extends FunSuite with TestContainersForAll with DaprServerTestBase: +class WorkflowCapabilityServerTest extends FunSuite with TestContainersForAll with DaprServerTestBase with RedisFixture: type Containers = DaprTestContainer @@ -47,15 +47,14 @@ class WorkflowCapabilityServerTest extends FunSuite with TestContainersForAll wi // Start the sidecar FIRST: the workflow runtime connects outbound to the sidecar's gRPC // endpoint, which Testcontainers maps to a dynamic host port. The runtime must be told that // port (not the default 50001), so the sidecar has to exist before we start the app server. + val res = startRedis(org.testcontainers.containers.Network.SHARED) val c = DaprTestContainer( DaprContainer(DaprTestContainer.DefaultImage) .withNetwork(org.testcontainers.containers.Network.SHARED) .withAppName("workflow-server-test") .withAppPort(appPort) .withAppChannelAddress("host.testcontainers.internal") - .withComponent( - Component("statestore", "state.in-memory", "v1", java.util.Map.of("actorStateStore", "true")), - ), + .withComponent(res.component("statestore")), ) c.start() diff --git a/test/unit/BindingDispatchTest.scala b/test/jvm/unit/BindingDispatchTest.scala similarity index 99% rename from test/unit/BindingDispatchTest.scala rename to test/jvm/unit/BindingDispatchTest.scala index 0e11e6b..d24f009 100644 --- a/test/unit/BindingDispatchTest.scala +++ b/test/jvm/unit/BindingDispatchTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.unit import dapr4s.* diff --git a/test/unit/DaprServerTestBase.scala b/test/jvm/unit/DaprServerTestBase.scala similarity index 99% rename from test/unit/DaprServerTestBase.scala rename to test/jvm/unit/DaprServerTestBase.scala index 4073405..d17a0cd 100644 --- a/test/unit/DaprServerTestBase.scala +++ b/test/jvm/unit/DaprServerTestBase.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.unit import dapr4s.* diff --git a/test/unit/JobDispatchTest.scala b/test/jvm/unit/JobDispatchTest.scala similarity index 98% rename from test/unit/JobDispatchTest.scala rename to test/jvm/unit/JobDispatchTest.scala index 331ae6a..9819a89 100644 --- a/test/unit/JobDispatchTest.scala +++ b/test/jvm/unit/JobDispatchTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.unit import dapr4s.* diff --git a/test/jvm/unit/JvmCapabilityDerivationFixtures.scala b/test/jvm/unit/JvmCapabilityDerivationFixtures.scala new file mode 100644 index 0000000..26e20af --- /dev/null +++ b/test/jvm/unit/JvmCapabilityDerivationFixtures.scala @@ -0,0 +1,43 @@ +//> using target.platform "jvm" +package dapr4s.test.unit + +import dapr4s.* +import dapr4s.derivation.* +import scala.collection.mutable + +// Recording fake + derive trait for JvmCapabilityDerivationTest — the jobs slice of +// CapabilityDerivationFixtures. JVM-only: JobsCapability and Jobs.derive exist only on the JVM +// (the Dapr JS SDK has no jobs API). Unused capability methods are stubbed with `???` (never +// called by the derived facade under test). + +// ---- Jobs ------------------------------------------------------------------- + +trait JobClient: + def recur(data: Req, schedule: JobSchedule)(using JobsCapability, JsonCodec[Req]): Unit + def once(data: Req, dueTime: java.time.Instant)(using JobsCapability, JsonCodec[Req]): Unit + @name("recur") def fetch()(using JobsCapability): Option[JobDetails] +lazy val JobClient: JobClient = Jobs.derive[JobClient] + +@scala.caps.assumeSafe +final class FakeJobs extends JobsCapability: + val log: mutable.ListBuffer[String] = mutable.ListBuffer.empty + def schedule[T: JsonCodec]( + name: JobName, + data: T, + schedule: JobSchedule, + dueTime: Option[java.time.Instant], + repeats: Option[Int], + ttl: Option[java.time.Instant], + ): Unit = + log += s"schedule|${name.value}|${summon[JsonCodec[T]].encode(data)}" + def scheduleOnce[T: JsonCodec]( + name: JobName, + data: T, + dueTime: java.time.Instant, + ttl: Option[java.time.Instant], + ): Unit = + log += s"once|${name.value}|${summon[JsonCodec[T]].encode(data)}" + def get(name: JobName): Option[JobDetails] = + log += s"get|${name.value}" + None + def delete(name: JobName): Unit = ??? diff --git a/test/jvm/unit/JvmCapabilityDerivationTest.scala b/test/jvm/unit/JvmCapabilityDerivationTest.scala new file mode 100644 index 0000000..53c8ad7 --- /dev/null +++ b/test/jvm/unit/JvmCapabilityDerivationTest.scala @@ -0,0 +1,21 @@ +//> using target.platform "jvm" +package dapr4s.test.unit + +import dapr4s.* +import dapr4s.given +import munit.FunSuite + +/** The jobs slice of [[CapabilityDerivationTest]] — JVM-only because `Jobs.derive` and `JobsCapability` exist only on + * the JVM (the Dapr JS SDK has no jobs API). Fixtures live in [[JvmCapabilityDerivationFixtures]]. + */ +@scala.caps.assumeSafe +class JvmCapabilityDerivationTest extends FunSuite: + + test("Jobs: schedule, scheduleOnce, get"): + val fake = FakeJobs() + given JobsCapability = fake + val client = JobClient + client.recur(Req(1), JobSchedule.Every(scala.concurrent.duration.DurationInt(5).seconds)) + client.once(Req(2), java.time.Instant.EPOCH) + client.fetch() + assertEquals(fake.log.toList, List("schedule|recur|1", "once|once|2", "get|recur")) diff --git a/test/jvm/unit/JvmModelsTest.scala b/test/jvm/unit/JvmModelsTest.scala new file mode 100644 index 0000000..5ff101a --- /dev/null +++ b/test/jvm/unit/JvmModelsTest.scala @@ -0,0 +1,81 @@ +//> using target.platform "jvm" +package dapr4s.test.unit + +import dapr4s.* +import dapr4s.given +import munit.FunSuite + +/** Model tests for the JVM-only model types — `JobSchedule`/`JobDetails` and the `Conversation*` models. These types + * belong to the JVM-only `JobsCapability`/`ConversationCapability` (the Dapr JS SDK has no jobs or conversation API), + * so their cases cannot live in the cross-platform [[ModelsTest]]. + */ +@scala.caps.assumeSafe +class JvmModelsTest extends FunSuite: + + // ------------------------------------------------------------------------- + // Jobs + // ------------------------------------------------------------------------- + + test("JobSchedule cases hold their data"): + import scala.concurrent.duration.DurationInt + assertEquals(JobSchedule.Cron("0 30 * * * *").asInstanceOf[JobSchedule.Cron].expression, "0 30 * * * *") + assertEquals(JobSchedule.Every(5.seconds).asInstanceOf[JobSchedule.Every].period, 5.seconds) + + test("JobDetails holds all fields"): + val now = java.time.Instant.now() + val d = JobDetails( + name = JobName("j"), + data = Some(SerializedJson("\"x\"")), + scheduleExpression = Some("@every 5s"), + dueTime = Some(now), + repeats = Some(3), + ttl = None, + ) + assertEquals(d.name, JobName("j")) + assertEquals(d.repeats, Some(3)) + assertEquals(d.ttl, None) + + // ------------------------------------------------------------------------- + // Conversation: FinishReason / ToolChoice + // ------------------------------------------------------------------------- + + test("FinishReason.fromWire maps known reasons"): + assertEquals(FinishReason.fromWire("stop"), FinishReason.Stop) + assertEquals(FinishReason.fromWire("length"), FinishReason.Length) + assertEquals(FinishReason.fromWire("tool_calls"), FinishReason.ToolCalls) + assertEquals(FinishReason.fromWire("content_filter"), FinishReason.ContentFilter) + + test("FinishReason.fromWire is case-insensitive"): + assertEquals(FinishReason.fromWire("STOP"), FinishReason.Stop) + + test("FinishReason.fromWire preserves unknown values"): + assertEquals(FinishReason.fromWire("function_call"), FinishReason.Other("function_call")) + + test("ToolChoice.wireValue maps each case"): + assertEquals(ToolChoice.Auto.wireValue, "auto") + assertEquals(ToolChoice.None.wireValue, "none") + assertEquals(ToolChoice.Required.wireValue, "required") + assertEquals(ToolChoice.Named(ToolName("get_weather")).wireValue, "get_weather") + + // ------------------------------------------------------------------------- + // Conversation messages + // ------------------------------------------------------------------------- + + test("ConversationMessage smart constructors set the role"): + assertEquals(ConversationMessage.system("s").role, ConversationMessageRole.System) + assertEquals(ConversationMessage.user("u").role, ConversationMessageRole.User) + assertEquals(ConversationMessage.assistant("a").role, ConversationMessageRole.Assistant) + assertEquals(ConversationMessage.developer("d").role, ConversationMessageRole.Developer) + assertEquals(ConversationMessage.tool("t", Some("fn")).name, Some("fn")) + + test("ConversationMessageRole enum values are distinct"): + assertEquals( + List( + ConversationMessageRole.System, + ConversationMessageRole.User, + ConversationMessageRole.Assistant, + ConversationMessageRole.Tool, + ConversationMessageRole.Developer, + ).distinct.size, + 5, + ) diff --git a/test/jvm/unit/JvmServerRouteDerivationTest.scala b/test/jvm/unit/JvmServerRouteDerivationTest.scala new file mode 100644 index 0000000..4f61711 --- /dev/null +++ b/test/jvm/unit/JvmServerRouteDerivationTest.scala @@ -0,0 +1,30 @@ +//> using target.platform "jvm" +package dapr4s.test.unit + +import dapr4s.* +import dapr4s.given +import dapr4s.derivation.* +import munit.FunSuite +import scala.collection.mutable + +/** The jobs-contract slice of [[ServerRouteDerivationTest]] — JVM-only because the `ReportJobs` scheduling contract + * mentions `JobsCapability`/`JobSchedule`/`JobDetails`, which exist only on the JVM (the Dapr JS SDK has no jobs API). + * `JobRoutes.deriveChecked` itself is shared, but a checked derivation needs the contract trait, and that trait cannot + * compile on Scala.js. The plain `JobRoutes.derive` case (no contract) stays in the shared suite, dispatching to the + * shared [[ReportJobHandlers]]. + */ +trait ReportJobs: + def nightly(spec: Req, schedule: JobSchedule)(using JobsCapability, JsonCodec[Req]): Unit + @name("nightly") def status()(using JobsCapability): Option[JobDetails] // getter: not a trigger + +@scala.caps.assumeSafe +class JvmServerRouteDerivationTest extends FunSuite: + + test("JobRoutes.deriveChecked: checks scheduling contract by job name, skips getters"): + val rec = new Recorder { val log = mutable.ListBuffer.empty[String] } + given Recorder = rec + // ReportJobs schedules "nightly" (payload Req) and has a "nightly" getter (skipped). + val routes = JobRoutes.deriveChecked[ReportJobs, ReportJobHandlers.type] + assertEquals(routes.map(_.name.value), List("nightly")) + routes.head.rawHandler.asInstanceOf[Any => Any](Req(2)) + assertEquals(rec.log.toList, List("job2")) diff --git a/test/unit/SubscriberTest.scala b/test/jvm/unit/SubscriberTest.scala similarity index 99% rename from test/unit/SubscriberTest.scala rename to test/jvm/unit/SubscriberTest.scala index 7a03f84..ba8daaf 100644 --- a/test/unit/SubscriberTest.scala +++ b/test/jvm/unit/SubscriberTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.unit import dapr4s.* diff --git a/test/TestOptionCodec.scala b/test/shared/TestOptionCodec.scala similarity index 100% rename from test/TestOptionCodec.scala rename to test/shared/TestOptionCodec.scala diff --git a/test/integration/apps/CounterActorApp.scala b/test/shared/apps/CounterActorApp.scala similarity index 100% rename from test/integration/apps/CounterActorApp.scala rename to test/shared/apps/CounterActorApp.scala diff --git a/test/integration/apps/CounterActorShared.scala b/test/shared/apps/CounterActorShared.scala similarity index 100% rename from test/integration/apps/CounterActorShared.scala rename to test/shared/apps/CounterActorShared.scala diff --git a/test/integration/apps/EchoServiceClient.scala b/test/shared/apps/EchoServiceClient.scala similarity index 100% rename from test/integration/apps/EchoServiceClient.scala rename to test/shared/apps/EchoServiceClient.scala diff --git a/test/integration/apps/InventoryServiceApp.scala b/test/shared/apps/InventoryServiceApp.scala similarity index 100% rename from test/integration/apps/InventoryServiceApp.scala rename to test/shared/apps/InventoryServiceApp.scala diff --git a/test/integration/apps/OrderServiceApp.scala b/test/shared/apps/OrderServiceApp.scala similarity index 100% rename from test/integration/apps/OrderServiceApp.scala rename to test/shared/apps/OrderServiceApp.scala diff --git a/test/integration/apps/Shared.scala b/test/shared/apps/Shared.scala similarity index 100% rename from test/integration/apps/Shared.scala rename to test/shared/apps/Shared.scala diff --git a/test/integration/apps/TestDurations.scala b/test/shared/apps/TestDurations.scala similarity index 100% rename from test/integration/apps/TestDurations.scala rename to test/shared/apps/TestDurations.scala diff --git a/test/integration/apps/TestUpickleCodec.scala b/test/shared/apps/TestUpickleCodec.scala similarity index 100% rename from test/integration/apps/TestUpickleCodec.scala rename to test/shared/apps/TestUpickleCodec.scala diff --git a/test/integration/apps/WorkflowApp.scala b/test/shared/apps/WorkflowApp.scala similarity index 100% rename from test/integration/apps/WorkflowApp.scala rename to test/shared/apps/WorkflowApp.scala diff --git a/test/shared/scenarios/ConfigurationScenarios.scala b/test/shared/scenarios/ConfigurationScenarios.scala new file mode 100644 index 0000000..1238670 --- /dev/null +++ b/test/shared/scenarios/ConfigurationScenarios.scala @@ -0,0 +1,28 @@ +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import munit.Assertions +import unsafeExceptions.canThrowAny + +/** Direct-call [[ConfigurationCapability]] scenarios shared by the JVM and JS integration suites, against the canonical + * `configuration.redis` store. Items are seeded into redis as `value||version` by both harnesses (see + * [[ItNames.ConfigKeyA]]). Configuration is gRPC-only in the JS SDK, so the JS twin exercises the gRPC alpha1 client. + */ +trait ConfigurationScenarios: + self: Assertions => + + def getReturnsSeededItems(using DaprCapability): Unit = + DaprCapability.configuration(ItNames.ConfigStore): + val items = ConfigurationCapability.get(Seq(ItNames.ConfigKeyA, ItNames.ConfigKeyB)) + val a = items.getOrElse(ItNames.ConfigKeyA, fail(s"missing ${ItNames.ConfigKeyA} in $items")) + val b = items.getOrElse(ItNames.ConfigKeyB, fail(s"missing ${ItNames.ConfigKeyB} in $items")) + assertEquals(a.value, ConfigurationValue("alpha")) + assertEquals(a.version, ConfigurationVersion("v1")) + assertEquals(b.value, ConfigurationValue("beta")) + assertEquals(b.version, ConfigurationVersion("v2")) + + def getUnknownKeyReturnsNoItem(using DaprCapability): Unit = + DaprCapability.configuration(ItNames.ConfigStore): + val absent = ConfigurationKey(ItNames.fresh("dapr4s-it-absent")) + assertEquals(ConfigurationCapability.get(Seq(absent)).get(absent), None) diff --git a/test/shared/scenarios/CryptoScenarios.scala b/test/shared/scenarios/CryptoScenarios.scala new file mode 100644 index 0000000..1e16bb4 --- /dev/null +++ b/test/shared/scenarios/CryptoScenarios.scala @@ -0,0 +1,26 @@ +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import munit.Assertions +import unsafeExceptions.canThrowAny + +/** Direct-call [[CryptoCapability]] scenarios shared by the JVM and JS integration suites, against the canonical + * `crypto.dapr.localstorage` store backed by a fresh RSA key (`rsa-key`). Crypto is gRPC-only in the JS SDK, so the JS + * twin exercises the gRPC alpha1 streaming wire API. + */ +trait CryptoScenarios: + self: Assertions => + + def encryptDecryptStringRoundTrip(using DaprCapability): Unit = + DaprCapability.crypto(ItNames.CryptoStore): + val plaintext = "the quick brown fox" + val cipher = CryptoCapability.encryptString(ItNames.CryptoKey, plaintext, KeyWrapAlgorithm.Rsa) + assert(cipher.nonEmpty, "ciphertext should not be empty") + assertEquals(CryptoCapability.decryptString(cipher), plaintext) + + def encryptDecryptBytesRoundTrip(using DaprCapability): Unit = + DaprCapability.crypto(ItNames.CryptoStore): + val data = Charsets.encodeString("payload-bytes", Charsets.Utf8) + val cipher = CryptoCapability.encrypt(ItNames.CryptoKey, data, KeyWrapAlgorithm.Rsa) + assertEquals(CryptoCapability.decrypt(cipher), data) diff --git a/test/shared/scenarios/InvokeScenarios.scala b/test/shared/scenarios/InvokeScenarios.scala new file mode 100644 index 0000000..15b61fd --- /dev/null +++ b/test/shared/scenarios/InvokeScenarios.scala @@ -0,0 +1,45 @@ +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import dapr4s.test.integration.apps.{CounterState, EchoService, IncrRequest} +import munit.Assertions +import unsafeExceptions.canThrowAny + +/** Direct-call [[InvokeCapability]] scenarios shared by the JVM and JS integration suites: the caller side of service + * invocation against an app server that registers the `echo`, `echo-int` and `double` routes (the JVM in-test + * [[dapr4s.internal.DaprAppServer]] / the JS `JsItServerApp`). + * + * Two hooks the platforms supply, because the server bring-up genuinely differs: + * - [[serverAppId]] — the app id the harness registered the routes under; + * - [[retrying]] — wraps the first sidecar call: the JS app channel warms up slightly after daprd reports healthy + * (the JVM polls sidecar health up front), so JS supplies `retryUntilSuccess` and the JVM supplies identity. + */ +trait InvokeScenarios: + self: Assertions => + + protected def serverAppId: AppId + protected def retrying[T](label: String)(body: => T): T + + def echoRoundtrip(using DaprCapability): Unit = + DaprCapability.invoke: + val resp = retrying("echo")(InvokeCapability.invoke(serverAppId, InvokeMethodName("echo"), "hello")[String]) + assertEquals(resp, "hello") + + def falsyZeroBodyRoundtrips(using DaprCapability): Unit = + // Exercises the raw-fetch fallback in the JS client (the SDK drops JS-falsy request bodies). + DaprCapability.invoke: + val resp = retrying("echo-int")(InvokeCapability.invoke(serverAppId, InvokeMethodName("echo-int"), 0)[Int]) + assertEquals(resp, 0) + + def derivedEchoServiceFacade(using DaprCapability): Unit = + DaprCapability.invoke: + val service = EchoService(serverAppId) + assertEquals(retrying("derived-echo")(service.echo("derived")), "derived") + assertEquals(service.double(IncrRequest(21)), CounterState(42)) + + def nonexistentAppThrows(using DaprCapability): Unit = + DaprCapability.invoke: + val attempt = + scala.util.Try(InvokeCapability.invoke(AppId("no-such-app"), InvokeMethodName("method"), "data")[String]) + assert(attempt.isFailure, s"invoking a non-existent app should throw, got: $attempt") diff --git a/test/shared/scenarios/ItNames.scala b/test/shared/scenarios/ItNames.scala new file mode 100644 index 0000000..069717f --- /dev/null +++ b/test/shared/scenarios/ItNames.scala @@ -0,0 +1,36 @@ +package dapr4s.test.integration + +import dapr4s.* + +/** Canonical Dapr component names and seeded fixtures shared by every integration suite on BOTH platforms. These match + * scripts/it/components/.yaml (the single source of truth for the component definitions) and + * scripts/it/secrets.json. The JVM fixture ([[SharedDaprItSuite]]) and the JS env ([[JsItEnv]]) both reference these, + * as do the shared scenario traits — so a name is declared once. + */ +object ItNames: + val StateStore: StateStoreName = StateStoreName("statestore") + val PubSub: PubSubName = PubSubName("pubsub") + val LockStore: LockStoreName = LockStoreName("lockstore") + val ConfigStore: ConfigurationStoreName = ConfigurationStoreName("configstore") + val SecretStore: SecretStoreName = SecretStoreName("secretstore") + val CryptoStore: CryptoComponentName = CryptoComponentName("cryptostore") + val CryptoKey: CryptoKeyName = CryptoKeyName("rsa-key") + + /** Configuration items both harnesses seed into redis as `value||version`. */ + val ConfigKeyA: ConfigurationKey = ConfigurationKey("dapr4s-it-cfg-a") + val ConfigKeyB: ConfigurationKey = ConfigurationKey("dapr4s-it-cfg-b") + + /** Secrets both harnesses seed via scripts/it/secrets.json. */ + val SecretKeyA: SecretKey = SecretKey("it-secret-a") + val SecretValueA: SecretValue = SecretValue("secret-value-alpha") + val SecretKeyB: SecretKey = SecretKey("it-secret-b") + val SecretValueB: SecretValue = SecretValue("secret-value-beta") + + /** Monotonic, link-safe unique suffix for test resources (NOT `java.util.UUID`: it does not link on Scala.js — + * reaches for `SecureRandom`). Uniqueness across one harness run is all that is needed; munit runs a suite's tests + * sequentially, so a plain counter is sufficient. + */ + private var counter: Long = 0L + def fresh(prefix: String): String = + counter += 1 + s"$prefix-${System.currentTimeMillis()}-$counter" diff --git a/test/shared/scenarios/LockScenarios.scala b/test/shared/scenarios/LockScenarios.scala new file mode 100644 index 0000000..d424487 --- /dev/null +++ b/test/shared/scenarios/LockScenarios.scala @@ -0,0 +1,34 @@ +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import munit.Assertions +import scala.concurrent.duration.DurationInt +import unsafeExceptions.canThrowAny + +/** Direct-call [[LockCapability]] scenarios shared by the JVM and JS integration suites, against the canonical + * `lock.redis` store. Unique resource IDs per call keep the shared sidecar contention-free. + */ +trait LockScenarios: + self: Assertions => + + private def res() = LockResourceId(ItNames.fresh("res")) + private def owner() = LockOwner(ItNames.fresh("owner")) + + def tryLockFreeReturnsTrue(using DaprCapability): Unit = + DaprCapability.lock(ItNames.LockStore): + assert(LockCapability.tryLock(res(), owner(), 30.seconds)) + + def tryLockHeldReturnsFalse(using DaprCapability): Unit = + DaprCapability.lock(ItNames.LockStore): + val r = res() + assert(LockCapability.tryLock(r, owner(), 30.seconds), "first tryLock should succeed") + assert(!LockCapability.tryLock(r, owner(), 30.seconds), "second tryLock should be contended") + + def unlockByOwnerThenLockNotFound(using DaprCapability): Unit = + DaprCapability.lock(ItNames.LockStore): + val r = res() + val o = owner() + assert(LockCapability.tryLock(r, o, 30.seconds)) + assertEquals(LockCapability.unlock(r, o), UnlockStatus.Success) + assertEquals(LockCapability.unlock(r, o), UnlockStatus.LockNotFound) diff --git a/test/shared/scenarios/SecretsScenarios.scala b/test/shared/scenarios/SecretsScenarios.scala new file mode 100644 index 0000000..164da9c --- /dev/null +++ b/test/shared/scenarios/SecretsScenarios.scala @@ -0,0 +1,44 @@ +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import munit.Assertions +import unsafeExceptions.canThrowAny + +/** Direct-call [[SecretsCapability]] scenarios shared by the JVM and JS integration suites, against the canonical + * `secretstores.local.file` store seeded from scripts/it/secrets.json. + * + * A missing key THROWS rather than returning `None`: the local-file store answers 500, which the impl surfaces as a + * thrown error on both platforms (None is returned only when the call succeeds but the response lacks the key). The + * exception type differs per platform, so it is checked structurally via `Try(...).isFailure`. + */ +trait SecretsScenarios: + self: Assertions => + + def getSeededReturnsSome(using DaprCapability): Unit = + DaprCapability.secrets(ItNames.SecretStore): + assertEquals(SecretsCapability.get(ItNames.SecretKeyA), Some(ItNames.SecretValueA)) + assertEquals(SecretsCapability.get(ItNames.SecretKeyB), Some(ItNames.SecretValueB)) + + def getBulkContainsSeeded(using DaprCapability): Unit = + DaprCapability.secrets(ItNames.SecretStore): + val bulk = SecretsCapability.getBulk() + // local.file getBulk nests {secretName: {key: value}}; dapr4s flattens to compound keys. + assert( + bulk.exists((k, v) => k.value.contains(ItNames.SecretKeyA.value) && v == ItNames.SecretValueA), + s"expected ${ItNames.SecretKeyA.value} in bulk; got keys: ${bulk.keys.map(_.value).toList.sorted}", + ) + assert( + bulk.exists((k, v) => k.value.contains(ItNames.SecretKeyB.value) && v == ItNames.SecretValueB), + s"expected ${ItNames.SecretKeyB.value} in bulk", + ) + + def getMissingKeyThrows(using DaprCapability): Unit = + DaprCapability.secrets(ItNames.SecretStore): + val attempt = scala.util.Try(SecretsCapability.get(SecretKey(ItNames.fresh("absent")))) + assert(attempt.isFailure, s"expected a missing secret to throw, got: $attempt") + + def getFromUnknownStoreThrows(using DaprCapability): Unit = + DaprCapability.secrets(SecretStoreName("nonexistent-store")): + val attempt = scala.util.Try(SecretsCapability.get(SecretKey("any-key"))) + assert(attempt.isFailure, s"expected an unknown store to throw, got: $attempt") diff --git a/test/shared/scenarios/StateScenarios.scala b/test/shared/scenarios/StateScenarios.scala new file mode 100644 index 0000000..19367eb --- /dev/null +++ b/test/shared/scenarios/StateScenarios.scala @@ -0,0 +1,99 @@ +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import munit.Assertions +import unsafeExceptions.canThrowAny + +/** Direct-call [[StateCapability]] scenarios shared by the JVM and JS integration suites. Each method performs the + * capability calls and the munit assertions; the per-platform suite owns only bring-up and the sync/`Future` boundary + * (see [[SharedDaprItSuite]] on JVM, the `js.async{}.toFuture` shells on JS). All run against the canonical + * `statestore` (state.redis) on both platforms. + * + * Keys are unique per call ([[ItNames.fresh]]) so scenarios sharing one sidecar do not interfere. + */ +trait StateScenarios: + self: Assertions => + + def saveThenGet(using DaprCapability): Unit = + DaprCapability.state(ItNames.StateStore): + val k = StateStoreKey(ItNames.fresh("k")) + StateCapability.save(k, "hello") + assertEquals(StateCapability.get[String](k), Some("hello")) + + def getMissingReturnsNone(using DaprCapability): Unit = + DaprCapability.state(ItNames.StateStore): + assertEquals(StateCapability.get[String](StateStoreKey(ItNames.fresh("absent"))), None) + + def getWithETagAfterSave(using DaprCapability): Unit = + DaprCapability.state(ItNames.StateStore): + val k = StateStoreKey(ItNames.fresh("k")) + StateCapability.save(k, "world") + val e = StateCapability.getWithETag[String](k) + assertEquals(e.value, Some("world")) + assert(e.etag.isDefined, "ETag should be present after save") + + def getWithETagMissingReturnsNone(using DaprCapability): Unit = + DaprCapability.state(ItNames.StateStore): + val e = StateCapability.getWithETag[String](StateStoreKey(ItNames.fresh("absent"))) + assertEquals(e.value, None) + assertEquals(e.etag, None) + + def saveWithETagSucceedsThenConflicts(using DaprCapability): Unit = + DaprCapability.state(ItNames.StateStore): + val k = StateStoreKey(ItNames.fresh("k")) + StateCapability.save(k, "v1") + val etag = StateCapability.getWithETag[String](k).etag.getOrElse(fail("expected an etag after save")) + assertEquals(StateCapability.saveWithETag(k, "v2", etag), None, "save with the current etag should succeed") + // The successful save bumped the server-side etag, so `etag` is now STALE — a genuine + // optimistic-concurrency conflict. A fabricated string would NOT do: Redis etags are + // integers, and daprd rejects a non-numeric etag with 400 (invalid etag) rather than a + // conflict — the exact reason both platforms reuse the stale-but-real etag here. + assert( + StateCapability.saveWithETag(k, "v3", etag).isDefined, + "save with a stale etag should report a conflict", + ) + assertEquals(StateCapability.get[String](k), Some("v2")) + + def delete(using DaprCapability): Unit = + DaprCapability.state(ItNames.StateStore): + val k = StateStoreKey(ItNames.fresh("k")) + StateCapability.save(k, "bye") + StateCapability.delete(k) + assertEquals(StateCapability.get[String](k), None) + + def deleteWithETagConflictThenSucceeds(using DaprCapability): Unit = + DaprCapability.state(ItNames.StateStore): + val k = StateStoreKey(ItNames.fresh("k")) + StateCapability.save(k, "x") + val stale = StateCapability.getWithETag[String](k).etag.getOrElse(fail("expected an etag")) + StateCapability.save(k, "x2") // bumps the server-side etag, so `stale` is now outdated + // Stale-but-real etag → genuine conflict (see saveWithETagSucceedsThenConflicts on why a + // fabricated string is wrong for Redis). + assert(StateCapability.deleteWithETag(k, stale).isDefined, "delete with a stale etag should conflict") + assert(StateCapability.get[String](k).isDefined, "key should survive a conflicting delete") + val current = StateCapability.getWithETag[String](k).etag.getOrElse(fail("expected an etag")) + assertEquals(StateCapability.deleteWithETag(k, current), None, "delete with the current etag should succeed") + assertEquals(StateCapability.get[String](k), None) + + def saveBulkAndGetBulk(using DaprCapability): Unit = + DaprCapability.state(ItNames.StateStore): + val ka = StateStoreKey(ItNames.fresh("k")) + val kb = StateStoreKey(ItNames.fresh("k")) + val absent = StateStoreKey(ItNames.fresh("absent")) + StateCapability.saveBulk[String](List(ka -> "alpha", kb -> "beta")) + val results = StateCapability.getBulk[String](Seq(ka, kb, absent)) + assertEquals(results.get(ka).flatMap(_.value), Some("alpha")) + assertEquals(results.get(kb).flatMap(_.value), Some("beta")) + assertEquals(results.get(absent).flatMap(_.value), None) + + def transactionUpsertsAndDeletes(using DaprCapability): Unit = + DaprCapability.state(ItNames.StateStore): + val kAdd = StateStoreKey(ItNames.fresh("k")) + val kDel = StateStoreKey(ItNames.fresh("k")) + StateCapability.save(kDel, "gone") + StateCapability.transaction( + Seq(StateOp.UpsertOp[String](kAdd, "new"), StateOp.DeleteOp(kDel)), + ) + assertEquals(StateCapability.get[String](kAdd), Some("new")) + assertEquals(StateCapability.get[String](kDel), None) diff --git a/test/unit/ActorDefinitionsTest.scala b/test/shared/unit/ActorDefinitionsTest.scala similarity index 100% rename from test/unit/ActorDefinitionsTest.scala rename to test/shared/unit/ActorDefinitionsTest.scala diff --git a/test/unit/CCTest.scala b/test/shared/unit/CCTest.scala similarity index 100% rename from test/unit/CCTest.scala rename to test/shared/unit/CCTest.scala diff --git a/test/unit/CapabilityDerivationFixtures.scala b/test/shared/unit/CapabilityDerivationFixtures.scala similarity index 90% rename from test/unit/CapabilityDerivationFixtures.scala rename to test/shared/unit/CapabilityDerivationFixtures.scala index 5acbbc7..b2d65f2 100644 --- a/test/unit/CapabilityDerivationFixtures.scala +++ b/test/shared/unit/CapabilityDerivationFixtures.scala @@ -115,32 +115,7 @@ final class FakeCrypto extends CryptoCapability: ArraySeq.from("ct".getBytes) def decrypt(ciphertext: ArraySeq[Byte]): ArraySeq[Byte] = ??? -// ---- Jobs ------------------------------------------------------------------- - -trait JobClient: - def recur(data: Req, schedule: JobSchedule)(using JobsCapability, JsonCodec[Req]): Unit - def once(data: Req, dueTime: java.time.Instant)(using JobsCapability, JsonCodec[Req]): Unit - @name("recur") def fetch()(using JobsCapability): Option[JobDetails] -lazy val JobClient: JobClient = Jobs.derive[JobClient] - -@scala.caps.assumeSafe -final class FakeJobs extends JobsCapability: - val log: mutable.ListBuffer[String] = mutable.ListBuffer.empty - def schedule[T: JsonCodec]( - name: JobName, - data: T, - schedule: JobSchedule, - dueTime: Option[java.time.Instant], - repeats: Option[Int], - ttl: Option[java.time.Instant], - ): Unit = - log += s"schedule|${name.value}|${summon[JsonCodec[T]].encode(data)}" - def scheduleOnce[T: JsonCodec](name: JobName, data: T, dueTime: java.time.Instant, ttl: Option[java.time.Instant]): Unit = - log += s"once|${name.value}|${summon[JsonCodec[T]].encode(data)}" - def get(name: JobName): Option[JobDetails] = - log += s"get|${name.value}" - None - def delete(name: JobName): Unit = ??? +// ---- Jobs: JVM-only (the Dapr JS SDK has no jobs API) — see JvmCapabilityDerivationFixtures. // ---- Workflow (client) ------------------------------------------------------ diff --git a/test/unit/CapabilityDerivationTest.scala b/test/shared/unit/CapabilityDerivationTest.scala similarity index 89% rename from test/unit/CapabilityDerivationTest.scala rename to test/shared/unit/CapabilityDerivationTest.scala index ba5dafb..475f1c2 100644 --- a/test/unit/CapabilityDerivationTest.scala +++ b/test/shared/unit/CapabilityDerivationTest.scala @@ -55,14 +55,7 @@ class CapabilityDerivationTest extends FunSuite: client.textKey("hello", KeyWrapAlgorithm("AES")) assertEquals(fake.log.toList, List("encrypt|rawKey|2|RSA", "encrypt|text-key|5|AES")) - test("Jobs: schedule, scheduleOnce, get"): - val fake = FakeJobs() - given JobsCapability = fake - val client = JobClient - client.recur(Req(1), JobSchedule.Every(scala.concurrent.duration.DurationInt(5).seconds)) - client.once(Req(2), java.time.Instant.EPOCH) - client.fetch() - assertEquals(fake.log.toList, List("schedule|recur|1", "once|once|2", "get|recur")) + // Jobs.derive is JVM-only (the Dapr JS SDK has no jobs API) — see JvmCapabilityDerivationTest. test("Workflow: start, startInput, startWithId, startWithIdInput"): val fake = FakeWorkflow() diff --git a/test/unit/CapabilityHandlerTest.scala b/test/shared/unit/CapabilityHandlerTest.scala similarity index 100% rename from test/unit/CapabilityHandlerTest.scala rename to test/shared/unit/CapabilityHandlerTest.scala diff --git a/test/unit/CharsetsTest.scala b/test/shared/unit/CharsetsTest.scala similarity index 100% rename from test/unit/CharsetsTest.scala rename to test/shared/unit/CharsetsTest.scala diff --git a/test/unit/DaprAppValidationTest.scala b/test/shared/unit/DaprAppValidationTest.scala similarity index 100% rename from test/unit/DaprAppValidationTest.scala rename to test/shared/unit/DaprAppValidationTest.scala diff --git a/test/unit/DerivationFixtures.scala b/test/shared/unit/DerivationFixtures.scala similarity index 100% rename from test/unit/DerivationFixtures.scala rename to test/shared/unit/DerivationFixtures.scala diff --git a/test/unit/InvokeDerivationTest.scala b/test/shared/unit/InvokeDerivationTest.scala similarity index 100% rename from test/unit/InvokeDerivationTest.scala rename to test/shared/unit/InvokeDerivationTest.scala diff --git a/test/unit/JsonCodecTest.scala b/test/shared/unit/JsonCodecTest.scala similarity index 100% rename from test/unit/JsonCodecTest.scala rename to test/shared/unit/JsonCodecTest.scala diff --git a/test/unit/ModelsTest.scala b/test/shared/unit/ModelsTest.scala similarity index 79% rename from test/unit/ModelsTest.scala rename to test/shared/unit/ModelsTest.scala index 7170f80..991f2dc 100644 --- a/test/unit/ModelsTest.scala +++ b/test/shared/unit/ModelsTest.scala @@ -126,28 +126,6 @@ class ModelsTest extends FunSuite: val result = BulkPublishResult(List(BulkEntryId("id-2"), BulkEntryId("id-3"))) assertEquals(result.failedEntries, List(BulkEntryId("id-2"), BulkEntryId("id-3"))) - // ------------------------------------------------------------------------- - // Conversation: FinishReason / ToolChoice - // ------------------------------------------------------------------------- - - test("FinishReason.fromWire maps known reasons"): - assertEquals(FinishReason.fromWire("stop"), FinishReason.Stop) - assertEquals(FinishReason.fromWire("length"), FinishReason.Length) - assertEquals(FinishReason.fromWire("tool_calls"), FinishReason.ToolCalls) - assertEquals(FinishReason.fromWire("content_filter"), FinishReason.ContentFilter) - - test("FinishReason.fromWire is case-insensitive"): - assertEquals(FinishReason.fromWire("STOP"), FinishReason.Stop) - - test("FinishReason.fromWire preserves unknown values"): - assertEquals(FinishReason.fromWire("function_call"), FinishReason.Other("function_call")) - - test("ToolChoice.wireValue maps each case"): - assertEquals(ToolChoice.Auto.wireValue, "auto") - assertEquals(ToolChoice.None.wireValue, "none") - assertEquals(ToolChoice.Required.wireValue, "required") - assertEquals(ToolChoice.Named(ToolName("get_weather")).wireValue, "get_weather") - // ------------------------------------------------------------------------- // Exceptions // ------------------------------------------------------------------------- @@ -288,24 +266,8 @@ class ModelsTest extends FunSuite: test("JobName rejects empty string"): intercept[IllegalArgumentException] { JobName("") } - test("JobSchedule cases hold their data"): - import scala.concurrent.duration.DurationInt - assertEquals(JobSchedule.Cron("0 30 * * * *").asInstanceOf[JobSchedule.Cron].expression, "0 30 * * * *") - assertEquals(JobSchedule.Every(5.seconds).asInstanceOf[JobSchedule.Every].period, 5.seconds) - - test("JobDetails holds all fields"): - val now = java.time.Instant.now() - val d = JobDetails( - name = JobName("j"), - data = Some(SerializedJson("\"x\"")), - scheduleExpression = Some("@every 5s"), - dueTime = Some(now), - repeats = Some(3), - ttl = None, - ) - assertEquals(d.name, JobName("j")) - assertEquals(d.repeats, Some(3)) - assertEquals(d.ttl, None) + // JobSchedule/JobDetails and the Conversation* models are JVM-only (the Dapr JS SDK has no + // jobs or conversation API) — their cases live in test/jvm/unit/JvmModelsTest.scala. // ------------------------------------------------------------------------- // Conversation @@ -316,22 +278,3 @@ class ModelsTest extends FunSuite: test("ConversationComponentName rejects empty string"): intercept[IllegalArgumentException] { ConversationComponentName("") } - - test("ConversationMessage smart constructors set the role"): - assertEquals(ConversationMessage.system("s").role, ConversationMessageRole.System) - assertEquals(ConversationMessage.user("u").role, ConversationMessageRole.User) - assertEquals(ConversationMessage.assistant("a").role, ConversationMessageRole.Assistant) - assertEquals(ConversationMessage.developer("d").role, ConversationMessageRole.Developer) - assertEquals(ConversationMessage.tool("t", Some("fn")).name, Some("fn")) - - test("ConversationMessageRole enum values are distinct"): - assertEquals( - List( - ConversationMessageRole.System, - ConversationMessageRole.User, - ConversationMessageRole.Assistant, - ConversationMessageRole.Tool, - ConversationMessageRole.Developer, - ).distinct.size, - 5, - ) diff --git a/test/unit/ServerRouteDerivationTest.scala b/test/shared/unit/ServerRouteDerivationTest.scala similarity index 88% rename from test/unit/ServerRouteDerivationTest.scala rename to test/shared/unit/ServerRouteDerivationTest.scala index 2c27d55..724ba1a 100644 --- a/test/unit/ServerRouteDerivationTest.scala +++ b/test/shared/unit/ServerRouteDerivationTest.scala @@ -41,11 +41,10 @@ object IngestHandlers: def orders(payload: Req)(using r: Recorder): Unit = r.log += s"ingest${payload.n}" @name("audit-log") def audit(payload: Req): Unit = () -/** Job-trigger handlers keyed by job name, plus the scheduling contract they answer. */ -trait ReportJobs: - def nightly(spec: Req, schedule: JobSchedule)(using JobsCapability, JsonCodec[Req]): Unit - @name("nightly") def status()(using JobsCapability): Option[JobDetails] // getter: not a trigger - +/** Job-trigger handlers keyed by job name. Cross-platform: answering job triggers needs no SDK support. The + * `ReportJobs` scheduling contract they answer is JVM-only (it mentions `JobsCapability`/`JobSchedule`) and lives in + * [[JvmServerRouteDerivationTest]] together with the `JobRoutes.deriveChecked` case. + */ object ReportJobHandlers: def nightly(spec: Req)(using r: Recorder): Unit = r.log += s"job${spec.n}" @@ -134,14 +133,8 @@ class ServerRouteDerivationTest extends FunSuite: val routes = JobRoutes.derive[ReportJobHandlers.type] assertEquals(routes.map(_.name.value), List("nightly")) - test("JobRoutes.deriveChecked: checks scheduling contract by job name, skips getters"): - val rec = new Recorder { val log = mutable.ListBuffer.empty[String] } - given Recorder = rec - // ReportJobs schedules "nightly" (payload Req) and has a "nightly" getter (skipped). - val routes = JobRoutes.deriveChecked[ReportJobs, ReportJobHandlers.type] - assertEquals(routes.map(_.name.value), List("nightly")) - routes.head.rawHandler.asInstanceOf[Any => Any](Req(2)) - assertEquals(rec.log.toList, List("job2")) + // JobRoutes.deriveChecked is exercised in JvmServerRouteDerivationTest: its Contract trait + // mentions the JVM-only JobsCapability/JobSchedule/JobDetails (the Dapr JS SDK has no jobs API). test("Subscriptions.derive without pubsubName uses the handler type's simple name"): val rec = new Recorder { val log = mutable.ListBuffer.empty[String] } diff --git a/test/unit/StateCapabilityTest.scala b/test/shared/unit/StateCapabilityTest.scala similarity index 100% rename from test/unit/StateCapabilityTest.scala rename to test/shared/unit/StateCapabilityTest.scala diff --git a/test/unit/WorkflowActivityDerivationFixtures.scala b/test/shared/unit/WorkflowActivityDerivationFixtures.scala similarity index 100% rename from test/unit/WorkflowActivityDerivationFixtures.scala rename to test/shared/unit/WorkflowActivityDerivationFixtures.scala diff --git a/test/unit/WorkflowActivityDerivationTest.scala b/test/shared/unit/WorkflowActivityDerivationTest.scala similarity index 100% rename from test/unit/WorkflowActivityDerivationTest.scala rename to test/shared/unit/WorkflowActivityDerivationTest.scala diff --git a/test/unit/WorkflowEventsTest.scala b/test/shared/unit/WorkflowEventsTest.scala similarity index 100% rename from test/unit/WorkflowEventsTest.scala rename to test/shared/unit/WorkflowEventsTest.scala diff --git a/wiki/dapr/dapr-java-sdk.md b/wiki/dapr/dapr-java-sdk.md index 08259c0..07113eb 100644 --- a/wiki/dapr/dapr-java-sdk.md +++ b/wiki/dapr/dapr-java-sdk.md @@ -227,6 +227,7 @@ All Dapr exceptions extend `DaprException extends RuntimeException`. They are co ## See Also - [Dapr Java SDK — Virtual Threads](dapr-java-sdk-virtual-threads.md) +- [Dapr JS SDK](dapr-js-sdk.md) — the Node.js counterpart (Promise-based; missing jobs & conversation) - [Dapr Overview](dapr-overview.md) - [Dapr Actors](dapr-actors.md) - [Dapr Workflows](dapr-workflows.md) diff --git a/wiki/dapr/dapr-js-sdk.md b/wiki/dapr/dapr-js-sdk.md new file mode 100644 index 0000000..bd8bb6d --- /dev/null +++ b/wiki/dapr/dapr-js-sdk.md @@ -0,0 +1,176 @@ +# Dapr JS SDK (@dapr/dapr) + +> Sources: dapr/js-sdk source @ a3be700 (= v3.18.0, published 2026-06-10), npm registry, v3.17.0/v3.18.0 release notes, 2026-06-11; live daprd 1.17 + redis findings from the dapr4s JS integration suite (worker reconnect bug, Redis etag semantics), 2026-06-12 +> Raw: [dapr-js-sdk source survey](../../raw/dapr/2026-06-11-dapr-js-sdk-source-survey.md) +> Updated: 2026-06-12 + +## Overview + +`@dapr/dapr` is the official Dapr SDK for Node.js (current: **3.18.0**). Unlike the reactive [Dapr Java SDK](dapr-java-sdk.md), it is Promise-based: `DaprClient` (outbound, HTTP or gRPC), `DaprServer` (inbound: pubsub subscriptions, input bindings, invocation listeners, actor hosting), actors (`ActorId`/`ActorProxyBuilder`/`AbstractActor`), and workflows (`DaprWorkflowClient`/`WorkflowRuntime`, with durabletask **vendored** into `src/workflow/internal/durabletask` since 3.17.0). It is the substrate for dapr4s's Scala.js platform (facades + [orphan js.await bridging](../scala-js/scala-js-async-jspi-wasm.md)). + +## Version, runtime targeting, module system + +- **Versioning policy** (since v3.17.0): major stays `3`; **the minor tracks the Dapr runtime minor** (3.17.0 ↔ runtime 1.17). +- **Node `>= 18.0.0`**, Node-only (node-fetch@2, express@4, `http2`, `stream`; no browser build). +- **CommonJS only** (`module: "commonjs"`, target ES2022; v3.18.0 switched generated protos back to CJS, PR #826). **No `exports` map, no `main` field**; compiled files sit at the package root, so Node resolves `index.js`. Everything is re-exported as **named exports from the root** (`src/index.ts`) — facades use `@JSImport("@dapr/dapr", "DaprClient")` etc. Deep requires work only because there's no exports map — unsupported; stick to root exports. +- Root exports include: `DaprClient`, `DaprServer`, `AbstractActor`, `ActorId`, `ActorProxyBuilder`, `Temporal` (re-export of `@js-temporal/polyfill`), `CommunicationProtocolEnum`, `HttpMethod`, `DaprWorkflowClient`, `WorkflowRuntime`, `WorkflowContext`, `WorkflowActivityContext`, `WorkflowState`, `WorkflowRuntimeStatus`, `TWorkflow`, `Task`, `LogLevel`, etc. The `IClient*`/`IServer*` interfaces and `*.type.ts` types are TypeScript-only (erased) — model as structural `js.Object` traits. +- gRPC transport since 3.17 is **ConnectRPC** (`@connectrpc/connect-node` + `@bufbuild/protobuf`); the vendored durabletask worker/client still uses `@grpc/grpc-js`. + +## DaprClient + +```ts +new DaprClient(options: Partial = {}) +start(): Promise; stop(): Promise; getIsInitialized(): boolean +``` + +`DaprClientOptions`: `daprHost` (default `"127.0.0.1"`), **`daprPort: string`** (STRING — default `"3500"` HTTP / `"50001"` gRPC), `communicationProtocol: CommunicationProtocolEnum` (default HTTP), `isKeepAlive?`, `logger?: LoggerOptions`, `actor?: ActorRuntimeOptions`, `daprApiToken?` (sent as `dapr-api-token`), `maxBodySizeMb?` (default 4). Env defaults: `DAPR_HTTP_PORT`, `DAPR_GRPC_PORT`, `DAPR_API_TOKEN`, `DAPR_HTTP_ENDPOINT`, `DAPR_GRPC_ENDPOINT`, `APP_ID` (JSDoc mentions `DAPR_PROTOCOL` but no env override actually exists). Non-numeric port → `Error("DAPR_INCORRECT_SIDECAR_PORT")`. + +Sub-clients (readonly fields) and key signatures: + +| field | methods | +|---|---| +| `state` | `save(storeName, stateObjects: KeyValuePairType[], options?)` · `get(storeName, key, options?): Promise` · `getBulk(storeName, keys, options?)` · `delete(storeName, key, options?)` · `transaction(storeName, operations?, metadata?)` · `query(storeName, query)` | +| `pubsub` | `publish(pubSubName, topic, data?, options?)` · `publishBulk(pubSubName, topic, messages, metadata?)` | +| `binding` | `send(bindingName, operation, data, metadata?)` | +| `invoker` | `invoke(appId, methodName, method: HttpMethod, data?, options?)` (default GET) | +| `secret` | `get(secretStoreName, key, metadata?)` · `getBulk(secretStoreName)` | +| `configuration` | `get` · `subscribe`/`subscribeWithKeys`/`subscribeWithMetadata` (cb gets `SubscribeConfigurationResponse`; stream = `{ stop: () => void }`) — **gRPC-only** | +| `lock` | `lock(storeName, resourceId, lockOwner, expiryInSeconds): Promise<{success: boolean}>` · `unlock(...): Promise<{status: LockStatus}>` (HTTP uses `v1.0-alpha1`) | +| `crypto` | `encrypt`/`decrypt`, overloaded: `(opts: EncryptRequest) => Promise` or `(inData: Buffer\|..., opts) => Promise` — **gRPC-only** | +| `workflow` | `scheduleNewWorkflow` · `getWorkflowState` · `terminate/pause/resume/purge` · `raiseEvent` (renamed in 3.18.0, PR #783; old `get/start/raise` deprecated) — **HTTP-only** (`v1.0-beta1` API); prefer `DaprWorkflowClient` | +| `actor` | `create(actorTypeClass): T` (wraps `ActorProxyBuilder`) | +| `proxy` | `create(cls, clientOptions?): Promise` — **gRPC-only** | +| `metadata` / `health` / `sidecar` | `get()`/`set(key, value)` · `isHealthy()` · `shutdown()` | + +## Per-protocol support matrix + +| Building block | HTTP | gRPC | +|---|---|---| +| state, pubsub publish, output bindings, invoke, secrets, lock, metadata/health/sidecar | yes | yes | +| `configuration`, `crypto`, `proxy` | **no** (`HTTPNotSupportedError`) | yes | +| `client.workflow` management | yes | **no** (`GRPCNotSupportedError`) | +| actor **hosting** (`DaprServer.actor`) | yes | **no** (`GRPCNotSupportedError`) | + +A single client protocol choice cannot serve all building blocks — dapr4s's JS layer holds an HTTP `DaprClient` plus a lazy gRPC one (configuration/crypto) plus a `DaprWorkflowClient` (own gRPC). + +## GrpcEndpoint scheme bug (live-sidecar-verified) + +The SDK parses `"daprHost:daprPort"` with its `HttpEndpoint`/`GrpcEndpoint` network classes, and `GrpcEndpoint` has a scheme-rendering bug that breaks the obvious spellings: + +- `GrpcEndpoint.setEndpoint` renders non-`dns` schemes as **`":host:port"`**. The workflow stack (`TaskHubGrpcWorker`/`TaskHubGrpcClient`) passes `GrpcEndpoint.endpoint` to grpc-js as the raw channel target, so `grpc://host` becomes the target `grpc:host:port` — which grpc-js cannot resolve (it falls back to `dns:grpc:host:port` → "Name resolution failed"). Same story for `grpcs://`. +- `setTls` only honours **`https:`** or **`?tls=true`** — `grpcs://` never sets TLS. + +The only spellings that work across *both* gRPC consumers (the workflow stack and `GRPCClient`, which reads just hostname/port/tls): + +| Intent | Working spelling | Why | +|---|---|---| +| plaintext | **bare host** (no scheme) | `GrpcEndpoint` applies its default `dns` scheme → the `dns:host:port` target grpc-js expects | +| TLS | **`https://host`** | the one spelling that both sets `tls = true` and still coerces the scheme to `dns` (at the price of a one-time `setScheme` deprecation `console.warn`) | + +(dapr4s's `DaprCapabilityImpl.hostAndPort` encodes exactly this mapping: `http`/`grpc` → bare host, `https`/`grpcs` → `https://host`.) + +## DaprServer + +```ts +new DaprServer(serverOptions: Partial = {}) +start(); stop() // start(): app server first, then client.start() (awaits sidecar) +readonly pubsub, binding, invoker, actor; client: DaprClient +``` + +`DaprServerOptions`: `serverHost` (default 127.0.0.1), **`serverPort: string`** (default `"3000"` HTTP / `"50000"` gRPC), `communicationProtocol`, `maxBodySizeMb?`, **`serverHttp?: express.Express`** (bring-your-own express app — dapr4s's hook for extra routes), `clientOptions?`, `logger?`. + +- **HTTP mode**: express 4 + body-parser (text, raw octet-stream, json incl. `application/cloudevents+json`); serves `GET /dapr/subscribe` from the programmatic subscription list — so **register all subscriptions/handlers before `start()`**. +- **gRPC mode**: Node `http2.createServer` + ConnectRPC `connectNodeAdapter` implementing `AppCallback` (`onInvoke`, `listTopicSubscriptions`, `onTopicEvent`, `listInputBindings`, `onBindingEvent`) + `AppCallbackAlpha` (`onBulkTopicEventAlpha1`, no-op `onJobEventAlpha1`). + +Server interfaces: + +```ts +// IServerPubSub +subscribe(pubSubName, topic, cb: (data, headers) => Promise, route?, metadata?) +subscribeWithOptions(pubsubName, topic, { metadata?, deadLetterTopic?, deadLetterCallback?, callback?, route?, bulkSubscribe? }) +subscribeToRoute(pubsubName, topic, route /* string | {rules: {match,path}[], default} */, cb) +subscribeBulk(...); getSubscriptions() +// return DaprPubSubStatusEnum.SUCCESS|RETRY|DROP ("SUCCESS"/"RETRY"/"DROP" strings); throw => RETRY + +// IServerBinding +receive(bindingName, cb: (data) => Promise) // HTTP: POST / + +// IServerInvoker +listen(methodName, cb: (data: DaprInvokerCallbackContent) => Promise, { method?: HttpMethod }) +// DaprInvokerCallbackContent: { body?: string /* JSON.stringify'd — re-parse! */; query?; metadata?; headers? } + +// IServerActor +init(): Promise; // MUST precede registerActor; registers actor HTTP routes +registerActor(cls); getRegisteredActors() +``` + +`HTTPServerActor.init()` registers `GET /healthz`, `GET /dapr/config`, `DELETE /actors/:type/:id`, `PUT /actors/:type/:id/method/:method`, `PUT .../method/timer/:timerName`, `PUT .../method/remind/:reminderName` — the same app-channel protocol dapr4s's JVM `DaprAppServer` implements. + +## Actors + +- **`ActorId`**: `new ActorId(id)`, `ActorId.createRandomId()`, `getId()`, `getURLSafeId()`. +- **`ActorProxyBuilder`**: ctor `(actorTypeClass, daprClient)` or `(actorTypeClass, host, port, communicationProtocol, clientOptions)`; `build(actorId): T` returns a **JS `Proxy`** forwarding every property access as an actor-method invocation. **Class-name reflection hazard:** the actor type string is **`actorTypeClass.name`** (and server-side `this.constructor.name`) — Scala.js class names are mangled/minified, so you must control the JS class `name` or bypass the proxy with raw sidecar HTTP (dapr4s mirrors its JVM `HttpActorContext` precedent). +- Low-level `IClientActor` (ActorClientHTTP/GRPC): `invoke(actorType, actorId, methodName, body?)`, `stateTransaction`, `stateGet`, `registerActorReminder/Timer` + unregister, `getActors()`. Reminder/timer durations are **`Temporal.Duration`** from the re-exported polyfill. +- **`AbstractActor`** (server): `constructor(daprClient, id)`; overrides `onActivate/onDeactivate/onActorMethodPre/onActorMethodPost/receiveReminder(data)`; helpers `registerActorReminder/Timer(...)`; `getStateManager(): ActorStateManager`. `ActorStateManager`: `getState`, `tryGetState(name): Promise<[boolean, T|null]>`, `setState`, `removeState`, `getOrAddState`, `addOrUpdateState`, `getStateNames`, `saveState` (auto-called after each method). Method dispatch is by JS property name — Scala.js actor methods need `@JSExport`-visible stable names. + +## Workflows + +- **`DaprWorkflowClient`** — talks gRPC **directly** to the sidecar via the vendored `TaskHubGrpcClient`: `scheduleNewWorkflow(workflow | name, input?, instanceId?, startAt?)`, `waitForWorkflowStart/Completion(id, fetchPayloads = true, timeoutInSeconds = 60)`, `getWorkflowState(id, getInputsAndOutputs)`, `terminateWorkflow`, `raiseEvent`, `purgeWorkflow`, `suspendWorkflow`, `resumeWorkflow`, `stop()`. +- **`WorkflowRuntime`**: `registerWorkflow(fn)`, `registerActivity(fn)`, `start()`, `stop()`. Name resolution uses **`fn.name`** — from Scala.js always use **`registerWorkflowWithName(name, fn)` / `registerActivityWithName(name, fn)`** and pass string names to `scheduleNewWorkflow`/`callActivity`. +- **Authoring model**: `TWorkflow = (context: WorkflowContext, input) => Generator | TOutput` — in practice an **`async function*` yielding `Task` objects**; the executor checks `result[Symbol.asyncIterator]` and drives `generator.next(prevResult)`. Plain (non-generator) return values complete the workflow immediately. **Scala.js cannot write `async function*`** — a facade must hand-implement the AsyncGenerator protocol (`next(v): js.Promise` + `[Symbol.asyncIterator]`); this is the hardest interop piece (dapr4s bridges it with a coroutine over two Promises inside `js.async`). +- **`WorkflowContext`**: `getWorkflowInstanceId`, `getCurrentUtcDateTime`, `isReplaying`, `createTimer(fireAt: Date | seconds)`, `callActivity(activity | name, input?)`, `callSubWorkflow`, `waitForExternalEvent(name)`, `continueAsNew(newInput, saveEvents)`, `setCustomStatus`, `whenAll`, `whenAny`. `WorkflowState` getters: `name`, `instanceId`, `runtimeStatus`, `createdAt`, `lastUpdatedAt`, `serializedInput/Output?`, `workflowFailureDetails?` (`getErrorType/getErrorMessage/getStackTrace`), `customStatus?`. `WorkflowRuntimeStatus`: numeric enum `RUNNING, COMPLETED, FAILED, TERMINATED, CONTINUED_AS_NEW, PENDING, SUSPENDED`. Inputs/outputs are JSON-serialized strings. + +### How the orchestration executor drives the generator (source-verified) + +If you hand-implement the AsyncGenerator protocol (dapr4s's `WorkflowCoroutine`), these are the exact driving rules of `worker/orchestration-executor.js` + `worker/runtime-orchestration-context.js`: + +- `const result = await fn(ctx, input)` — the registered function must **create** the generator without running its body; the executor duck-types it via `typeof result?.[Symbol.asyncIterator] === "function"` (and never actually invokes that member). +- It drives **`next()` and `throw()` only — `generator.return()` is never called** (no `.return(` call site in either file), so don't fake cancellation semantics for it. +- `run()`: one `await generator.next()`; `{done: true}` completes the orchestration with `value`, otherwise `value` becomes `_previousTask`. `resume()` (once per task-completing history event): failed task → `await generator.throw(task._exception)`; complete task → `await generator.next(task._result)` in a loop that keeps feeding while the newly yielded task is already complete (this loop is what makes replay work). +- **Every yielded `value` must be `instanceof` the vendored durabletask `Task`** — yield the SDK's own Task instances, never wrappers. +- **Post-done `next()` happens**: the context never clears `_previousTask`, so events arriving after the generator finished still trigger `resume()` — a finished generator must answer `{value: undefined, done: true}` per the standard protocol. +- The executor awaits every `next()`/`throw()` before processing the next history event — the strict-alternation property dapr4s's coroutine handshake relies on (see [the JSPI article's field notes](../scala-js/scala-js-async-jspi-wasm.md#field-notes-from-the-dapr4s-port)). + +### Workflow worker reconnect bug: `isFirstAttempt` kills the worker on the first stream loss (live-verified) + +**A daprd restart permanently kills the workflow worker** — upstream issue candidate. In the vendored `worker/task-hub-grpc-worker.js`, `internalRunWorker`'s reconnect loop guards with `let isFirstAttempt = true` ... `catch (err) { ...; if (isFirstAttempt) throw err; }` and only sets `isFirstAttempt = false` **after** the try/catch completes a full pass — i.e. after the work-item stream has already ended or errored once. The flag is meant to fail fast when the *initial connection* can't be established, but because it is not cleared on *successful* connection ("Successfully connected ... Waiting for work items"), the **first** stream error of an established worker — e.g. the sidecar restarting hours later — still satisfies `isFirstAttempt`, rethrows, and unwinds `internalRunWorker` entirely. `start()` runs it un-awaited with `.catch(err => { logger.error("Worker failed:", err); this._isRunning = false; })`, so the process keeps running but **no reconnect is ever attempted**: workflows and activities silently stop being processed (symptom: workflow scheduling succeeds but instances hang in RUNNING/PENDING forever). The exponential-backoff retry path is unreachable in practice — it only triggers from the *second* disruption onward, and the first one is always fatal. Workaround: restart the Node process whenever daprd restarts (or supervise the worker and recreate `WorkflowRuntime` on failure); also make test harnesses kill stale servers whose sidecar has been recycled (a stale server holding the app port makes its replacement die with `EADDRINUSE` while its own worker is dead — dapr4s's `js-integration-env.sh` does a belt-and-braces `pkill` for exactly this). + +### Deterministic-UUID gap + +The JS SDK's `WorkflowContext` has **no `newUuid`** (the Java SDK's `WorkflowContext.newUUID` has no counterpart). A port that needs replay-deterministic UUIDs must mirror the Java SDK's algorithm (`TaskOrchestrationExecutor.newUuid`): RFC 4122 §4.3 **name-based v5/SHA-1** over `"--"` in the fixed namespace **`9e952958-5e33-4daf-827f-2fa12937b875`**, with the version/variant bits patched into the truncated 128-bit hash. Deterministic because instanceId is constant, `getCurrentUtcDateTime` advances only via replayed ORCHESTRATORSTARTED history timestamps, and the per-execution counter restarts at 0 on every replay. (dapr4s: `WorkflowContextImpl.deterministicUuidV5`, hashing via `node:crypto` since the Scala.js javalib has no `MessageDigest`.) + +## Serialization + +Content type is **inferred from the JS value** unless overridden: `Object`/`Array` → `application/json` (or `application/cloudevents+json` if CloudEvent-shaped), `Boolean`/`Number`/`String` → `text/plain`, Buffer/TypedArray → `application/octet-stream`. Deserialization always **tries `JSON.parse` first**, falling back to the raw string (`tryParseJson`) — so `state.get` returns parsed JSON or a string. Metadata goes into HTTP query params; actor payloads use `BufferSerializer` (JSON in Buffers). + +## Error surfacing + +- HTTP client: non-2xx/3xx rejects with a plain **`Error` whose message is `JSON.stringify({ error: statusText, error_msg: bodyText, status })`** — no typed API-error hierarchy. gRPC: ConnectRPC `ConnectError`s. +- **Soft-failure response objects instead of rejections**: `pubsub.publish` → `{ error?: Error }`; `state.save`/`delete` → `{ error?: Error }`; `publishBulk` → `{ failedMessages: [{message, error}] }`. Check these, don't just await. +- **Redis etags are integers — 400 vs 409 (live-verified against daprd 1.17 + redis)**: the Redis state store's etags are version counters, so daprd rejects a *fabricated* non-numeric etag with **400 `ERR_STATE_SAVE` ("invalid etag value")**, not a conflict. A genuine **409 etag-mismatch conflict** only arises from a *stale but real* etag (save once, capture the etag, save again to bump it, then retry with the captured one). Tests that fake etags like `"wrong-etag"` are testing input validation, not optimistic concurrency. +- Typed: `GRPCNotSupportedError`, `HTTPNotSupportedError`, `PropertyRequiredError`; sidecar startup timeout → `Error("DAPR_SIDECAR_COULD_NOT_BE_STARTED")` (~60 × 500ms retries). Workflow activity failures: `TaskFailedError` via `Task.getException()`, client-side `WorkflowFailureDetails`. + +## Missing building blocks (vs the Java SDK) + +- **Jobs**: no client API at all — only a no-op `onJobEventAlpha1` gRPC server stub. +- **Conversation**: completely absent. +- **Client-side streaming pub/sub subscriptions**: absent (subscribe only via `DaprServer`). + +dapr4s makes the jobs/conversation capabilities **compile-time absent** on Scala.js (they live on a JVM-only platform trait — no runtime stub, no `UnsupportedOperationException`); client-side streaming subscriptions are simply not offered (subscribe via `serve()`). + +## Facade-writing gotchas (Scala.js) + +- **`CommunicationProtocolEnum` is numeric with `GRPC = 0`, `HTTP = 1`** — a facade defaulting to 0 silently picks gRPC. `HttpMethod` = lowercase strings (`"get"`, …); `DaprPubSubStatusEnum` = strings; `StateConcurrencyEnum`/`StateConsistencyEnum`/`LockStatus` numeric. +- **Ports are strings everywhere.** Options objects are `Partial<...>` — every facade field should be `js.UndefOr`. +- Ordering: all `subscribe`/`receive`/`listen`/`actor.init()+registerActor` **before `server.start()`**; `actor.init()` before `registerActor`. +- `invoker.listen` callback `body` is a **string** (`JSON.stringify(req.body)`) — re-parse on the Scala side. +- CJS + Node-only: with Scala.js use `ModuleKind.CommonJSModule`, or `ESModule` relying on Node's CJS-named-props interop. npm resolution is cwd-based under scala-cli (see [Cross-Building JVM + Scala.js with Scala CLI](../scala-js/scala-js-cross-building-scala-cli.md)). + +## See Also + +- [Dapr Java SDK](dapr-java-sdk.md) — the JVM counterpart (reactive Mono/Flux vs Promises; has jobs & conversation) +- [ScalablyTyped Facades with Scala CLI](../scala-js/scalablytyped-with-scala-cli.md) — how dapr4s generates its `typings.daprDapr` facade over this SDK (and the TS-vs-wire mismatches to watch for) +- [js.async, JSPI and the Wasm backend](../scala-js/scala-js-async-jspi-wasm.md) — how dapr4s turns these Promises back into direct style +- [Cross-Building JVM + Scala.js with Scala CLI](../scala-js/scala-js-cross-building-scala-cli.md) — build mechanics, npm resolution +- [Capture Checking on Scala.js](../scala-js/capture-checking-on-scala-js.md) — facades under explicit nulls + CC +- [Dapr Actors](dapr-actors.md), [Dapr Workflows](dapr-workflows.md) — the building blocks behind these APIs diff --git a/wiki/index.md b/wiki/index.md index b2f30d5..347186e 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -85,7 +85,7 @@ Type-driven design patterns for correct, self-documenting Scala code: ADTs, opaq ## dapr -Dapr (Distributed Application Runtime) — portable, event-driven runtime for building resilient microservices; covers architecture, building blocks, Java SDK, and testing. +Dapr (Distributed Application Runtime) — portable, event-driven runtime for building resilient microservices; covers architecture, building blocks, Java and JS SDKs, and testing. | Article | Summary | Updated | |---------|---------|---------| @@ -97,6 +97,7 @@ Dapr (Distributed Application Runtime) — portable, event-driven runtime for bu | [Dapr Actors](dapr/dapr-actors.md) | Virtual actor pattern, turn-based access, placement service, timers vs reminders | 2026-05-01 | | [Dapr Workflows](dapr/dapr-workflows.md) | Durable workflow orchestration, event sourcing, determinism requirement, activities | 2026-05-01 | | [Dapr Java SDK](dapr/dapr-java-sdk.md) | Java SDK structure, DaprClient API, actors SDK, workflows SDK, key usage patterns | 2026-05-01 | +| [Dapr JS SDK](dapr/dapr-js-sdk.md) | @dapr/dapr 3.18.0 API map: CommonJS/named root exports, DaprClient sub-clients, per-protocol support matrix, GrpcEndpoint scheme bug, DaprServer, actors (class-name reflection hazard), workflows (async-generator model, executor driving rules, deterministic-UUID gap, *WithName variants, isFirstAttempt worker-reconnect bug — daprd restart permanently kills the worker), serialization/error rules (incl. Redis integer-etag 400-vs-409), missing jobs/conversation | 2026-06-12 | | [Dapr Testcontainers](dapr/dapr-testcontainers.md) | Integration testing with DaprContainer, component/subscription setup, host app channel, QuotedBoolean, placement container, JUnit patterns, Spring Boot @ServiceConnection, multi-language | 2026-05-05 | | [Dapr E2E — Self-Hosted (dapr run)](dapr/dapr-e2e-selfhosted.md) | `dapr run -- java -cp ` pattern for host-JVM E2E tests; Mill forkArgs+assembly() to avoid deadlocks; Scala 3 testcontainers self-referential generic workaround; why not DaprContainer | 2026-05-27 | | [Dapr Other Building Blocks](dapr/dapr-other-building-blocks.md) | Bindings, secrets, configuration, distributed lock, cryptography, jobs | 2026-05-01 | @@ -149,3 +150,14 @@ Scala 3 libraries that derive an implementation FROM a trait (RPC clients, route | [tagless-redux](scala-rpc-derivation/tagless-redux.md) | `WireProtocol.derive` for tagless algebras (Kryo/Pekko/Boopickle); reflect rewrite of cats-tagless | 2026-06-07 | | [ZIO IsReloadable](scala-rpc-derivation/zio-isreloadable.md) | `IsReloadable[A].reloadable(scopedRef)` → reflect proxy forwarding to a ScopedRef; hot-reload; @experimental | 2026-06-07 | | [distage TraitConstructor](scala-rpc-derivation/distage-traitconstructor.md) | DI auto-implementation of an abstract trait → `Functoid[R]`; Symbol.newClass via reflection shim | 2026-06-07 | + +## scala-js + +Compiling Scala 3 (including capture-checked dapr4s) to JavaScript/WebAssembly — scala-cli cross-building and cross-publishing, the js.async/js.await + JSPI direct-style story, and capture checking on the JS backend. + +| Article | Summary | Updated | +|---------|---------|---------| +| [Cross-Building JVM + Scala.js with Scala CLI](scala-js/scala-js-cross-building-scala-cli.md) | `platform` directive (first = default), `--js`/`--cross`, per-file `target.platform`, `target.platform`-scoped deps files (plain `dep` IS platform-scoped; the leak is `test.dep`-only — `.test.scala` filename workaround), `--exclude` has no inverse, `::` dep syntax, publish --cross (_3 + _sjs1_3), scala-cli >= 1.13.0 floor, cwd-based npm resolution, GH Actions | 2026-06-12 | +| [js.async / js.await, JSPI, and the WebAssembly Backend](scala-js/scala-js-async-jspi-wasm.md) | js.async/js.await semantics (1.19.0+, ES2017+, Scala 3.8+), orphan await + allowOrphanJSAwait, Wasm backend restrictions, JSPI runtime matrix (Node 25+/Chrome 137+), no-intervening-JS-frame rule, rejected Atomics.wait/deasync alternatives, dapr4s's virtual-thread-parking analogue + port field notes (JSImport.Default for CJS, per-request js.async re-entry, AsyncGenerator-from-coroutine recipe) + munit-on-Wasm harness notes (js.async{}.toFuture, raw-Promise vacuous-pass footgun, wasm cleanup-bug wrapper, plain-JS linker wedge on orphan-await test sources, ESM resolution hook, --test-only ineffective on JS, UUID.randomUUID doesn't link) | 2026-06-12 | +| [ScalablyTyped Facades with Scala CLI](scala-js/scalablytyped-with-scala-cli.md) | Converter CLI (1.0.0-beta45) with scala-cli: flag landmines (pin --scala 3.3.6, full --scalajs version, -s es2022, typescript required), @types/* as top-level deps, deterministic npmVersion-digest coordinates from package-lock + converter tuple, ivy2Local zero-config resolution + CI caching, ESM gotchas (deep-module values, CJS default-export shim), MutableBuilder option traits, TS-vs-wire mismatches, the published-POM consumer problem + options | 2026-06-12 | +| [Capture Checking on Scala.js](scala-js/capture-checking-on-scala-js.md) | CC erased in picklerPhases before GenSJSIR; empirical probe (dapr4s nightly + full flag set passes on JS, zero warnings); explicit-nulls × js.native facades; sealed caps.Capability gotcha; munit/upickle _sjs1_3 availability | 2026-06-11 | diff --git a/wiki/log.md b/wiki/log.md index 0ee7f73..d589e2c 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1,5 +1,42 @@ # Wiki Log +## [2026-06-13] note | full JVM/JS test unification — one component set, redis everywhere, shared scenarios +- SUPERSEDES the 2026-06-12 "component-config mechanism stays platform-idiomatic" note below. The two platforms now SHARE the component definitions: scripts/it/components/*.yaml + scripts/it/secrets.json are the single source of truth, rendered per topology by scripts/it/render-components.sh (only env-specific value is redisHost — localhost:6391 for the JS host-network harness, redis:6379 for the JVM testcontainers network). The JS harness mounts the rendered dir into daprd; the JVM feeds the SAME files via `io.dapr.testcontainers.DaprContainer.withComponent(java.nio.file.Path)` (the file-ingesting overload — present in testcontainers-dapr 1.17.2, this is what makes shared files possible). No scripts/jvm-it/ twin needed and none of the old in-memory divergence: both platforms run state/pubsub/lock/configuration on redis, secrets on local.file, crypto on localstorage. +- Test logic is shared via traits in test/shared/scenarios (self: munit.Assertions =>, shared API + given DaprCapability). JVM and JS suites are thin shells owning only bring-up + the sync/Future boundary, then calling the same scenario methods. Direct-call capabilities (state/secrets/lock/crypto/configuration/invoke) fully share call+assertions; the JVM gained SharedDaprItSuite (single all-components redis sidecar) + JvmItComponents (renderer). Server-delivery suites (actor/workflow/pubsub delivery, app-level Order/Inventory/EndToEnd) keep platform-specific bring-up (a host DaprAppServer the sidecar calls back into vs the external JsTestServer) but run on the shared redis components via RedisFixture. Closed the JS invoke error-path gap (non-existent-app throws — was JVM-only). Verified: every JVM integration suite + the full JS Wasm+JSPI leg green on redis. Design in docs/JVM-JS-PARITY.md. +- Redis etag gotcha encoded in the shared StateScenarios: a stale-but-real (numeric) etag is required to provoke a conflict — a fabricated non-numeric string gets a 400 from daprd, not a 409 (the JVM suites previously hid this by using state.in-memory, which tolerates any mismatched etag). + +## [2026-06-12] note | JVM/JS integration-test coverage parity (cross-build rework) +- Context: closed the two asymmetric gaps where a shared capability was integration-tested on only one platform — added test/jvm/integration/ConfigurationCapabilityServerTest (JVM twin of the JS configuration suite: configuration.redis on a shared Docker network, `redis-cli MSET` seeding of `value||version`) and test/js/integration/CryptoJsIntegrationTest (JS twin of the JVM crypto suite: crypto.dapr.localstorage, RSA key generated per-run by scripts/js-integration-env.sh into the git-ignored scripts/js-it/keys/, gRPC-only path like the configuration suite). Now every JS-supported capability is integration-tested on both platforms; jobs/conversation remain JS-absent at compile time (not untested), and bindings is the lone shared capability with only derivation+unit coverage on both (symmetric). +- The component-config mechanism stays platform-idiomatic on purpose: JVM declares components in-code via testcontainers-dapr `withComponent(Component(...))`, JS mounts daprd component YAMLs — there is no scripts/jvm-it/components/ because testcontainers-dapr writes those files for you. Equivalence is in content (same component types, same seeding), not a shared source. Recorded in docs/DESIGN.md "Integration-test coverage parity". + +## [2026-06-12] ingest | self-contained _sjs1_3 artifact: outputPackage rename + compileOnly deps + facade embedding +- Raw sources: the dapr4s self-contained-artifact rework itself — scripts/generate-st-facades.sh (`--outputPackage dapr4styped`, digest churn), scripts/embed-st-facades.sh (cs-fetch-resolved transitive set → class/tasty/sjsir staging), js-deps.scala (compileOnly.dep + scalablytyped-runtime/scalajs-dom regular deps), converter CLI sources @ 1.0.0-beta45 (Main.scala `Name(x)`: outputPackage is a single identifier; dotted values backtick-escape) — no new raw files; the script headers and js-deps.scala comments are the canonical record +- Updated: scala-js/scalablytyped-with-scala-cli.md (the published-library consumer problem now has the implemented answer: outputPackage rename — single-identifier constraint, collision rationale, digest-relevant; `compileOnly.dep` verified on scala-cli 1.14 — platform-scoped like `using dep`, absent from the published POM entirely, not even `provided`; publish-time embedding via `--resource-dirs`; consumer proof method — publish local, hide ~/.ivy2/local/org.scalablytyped, compile+link+run a consumer to a Dapr-level error; previous ship-the-recipe approach demoted to alternatives) + +- Removed the index row for `scala3-language/scala-cli-build-tool.md` (article was never present on + disk — lost before the first commit) and redirected the `java-interop-safe-scala.md` See Also link + to `scala-js/scala-js-cross-building-scala-cli.md`, which now covers the same ground. + +## [2026-06-12] ingest | ScalablyTyped-with-scala-cli pipeline + JS test-harness field notes (dapr4s cross-build rework) +- Raw sources: the dapr4s cross-build rework itself — scripts/generate-st-facades.sh + js-deps.scala + package.json (converter pipeline), scripts/test-js-integration.sh + wasm-test.sh + scripts/js-it/ hooks + test/js/integration/ (harness findings, 8 suites/26 tests on Wasm+JSPI vs live daprd 1.17 + redis), node_modules @dapr/dapr 3.18.0 task-hub-grpc-worker.js — no new raw files; the script headers, js-deps.scala comments, and suite comments are the canonical record +- Created: scala-js/scalablytyped-with-scala-cli.md (converter CLI 1.0.0-beta45 with scala-cli: flag landmines — `--scala 3` resolves 3.7.3 and breaks std, pin 3.3.6; full --scalajs version; -s es2022 skips the broken dom stdlib; typescript npm package required — @types/* as top-level deps, deterministic npmVersion-digest coordinates from package-lock + converter tuple, ivy2Local zero-config resolution + CI cache paths, ESM gotchas (deep-module values unresolvable, CJS default-export shim), MutableBuilder option traits + TS-vs-wire mismatch patterns, the org.scalablytyped-not-on-Central consumer problem + options) +- Updated: scala-js/scala-js-cross-building-scala-cli.md (CORRECTION: plain `using dep` IS platform-scoped by a same-file target.platform directive — the leak is `test.dep`-only, `.test.scala`-filename workaround; replaced the jvm-deps.scala/--exclude pattern with the target.platform-scoped deps files pattern; added: no --include exists — positional re-include of excluded files is silently ignored) +- Updated: scala-js/scala-js-async-jspi-wasm.md (new munit-on-Wasm+JSPI harness notes: js.async{...}.toFuture per test + raw-js.Promise vacuous-pass footgun; scala-cli wasm DirectoryNotEmptyException-after-green-run cleanup bug + wrapper pattern; the plain-JS linker WEDGES on orphan-await test sources; scala-cli runs node from PATH with zero V8 flags → Node 25 floor; ESM resolution-hook pattern for npm deps in test runs; --test-only ineffective on the JS runner; UUID.randomUUID does not link — SecureRandom absent) +- Updated: dapr/dapr-js-sdk.md (task-hub-grpc-worker isFirstAttempt bug — the first stream error of an *established* worker still rethrows as a first-attempt failure, so a daprd restart permanently kills the workflow worker, reconnect never happens; upstream issue candidate. Redis integer-etag behaviour — fabricated non-numeric etag → 400 ERR_STATE_SAVE, a genuine 409 conflict needs a stale real etag. jobs/conversation note updated to dapr4s's compile-time absence) + +## [2026-06-11] ingest | Scala.js implementation learnings (dapr4s port field notes) +- Raw sources: the dapr4s Scala.js cross-build implementation itself (src/internal/js/ + facades), runtime-verified against node_modules @dapr/dapr 3.18.0 + express 4.22.2 and a live daprd sidecar — no new raw files; the verified findings live in the code's scaladocs (Express.scala, DaprCapabilityImpl.scala, WorkflowCoroutine.scala, WorkflowContextImpl.scala) +- Updated: scala-js/scala-js-async-jspi-wasm.md (new "Field notes from the dapr4s port" section: JSImport.Default is the one correct binding for CJS default-export modules under both module kinds; the per-request js.async re-entry pattern in express handlers; the AsyncGenerator-from-coroutine recipe with the strict-alternation safety argument) +- Updated: dapr/dapr-js-sdk.md (GrpcEndpoint scheme bug — `grpc://` renders the channel target `grpc:host:port`, which grpc-js cannot resolve; bare host or `https://` are the only working spellings, and setTls only honours `https:`/`?tls=true`; orchestration-executor generator-driving details — next/throw only, never return, yields must be instanceof the vendored Task, post-done next() happens; deterministic-UUID gap — no newUuid in the JS SDK, mirror the Java SDK's v5/SHA-1 algorithm with namespace 9e952958-5e33-4daf-827f-2fa12937b875) + +## [2026-06-11] ingest | Scala.js Cross-Build Research (scala-js topic) + Dapr JS SDK +- Raw sources (scala-js): empirical scala-cli 1.12.2/1.14.0 cross-platform probes (/tmp/sjs-probe et al.) + scala-cli.virtuslab.org docs/releases/issues; scala-js.org release notes 1.17.0–1.21.0 + WebAssembly backend docs + JSPI.scala + chromestatus/nodejs/synckit/cats-effect; empirical CC-on-Scala.js probe (/tmp/cc-js-probe, dapr4s nightly 3.10.0-RC1-bin-20260607-dec42ae) + scala/scala3 Compiler.scala/issue tracker +- Raw sources (dapr): dapr/js-sdk source survey @ a3be700 (= @dapr/dapr 3.18.0) + npm registry + v3.17.0/v3.18.0 release notes +- Created topic `scala-js`: scala-js-cross-building-scala-cli.md, scala-js-async-jspi-wasm.md, capture-checking-on-scala-js.md +- Created: dapr/dapr-js-sdk.md +- Updated: dapr/dapr-java-sdk.md (See Also cross-reference to the JS SDK) +- NOTE: context is the dapr4s Scala.js cross-build (direct-style API preserved via Wasm+JSPI orphan js.await; @dapr/dapr as the JS substrate). Pre-existing index issue spotted (not fixed here, ingest not lint): scala3-language/scala-cli-build-tool.md is referenced by index.md but missing on disk. + ## [2026-06-07] ingest | Scala 3 Metaprogramming + Trait-to-Implementation Derivation (RPC) - Raw sources (scala3-metaprogramming): docs.scala-lang.org/scala3/reference/metaprogramming {index, inline, compiletime-ops, macros, reflection, staging, tasty-inspect}; scala-hearth.readthedocs.io - Created topic `scala3-metaprogramming`: metaprogramming-overview.md, inline.md, compile-time-operations.md, macros-quotes-and-splices.md, tasty-reflection.md, runtime-staging-and-tasty-inspection.md, scala-hearth.md diff --git a/wiki/scala-js/capture-checking-on-scala-js.md b/wiki/scala-js/capture-checking-on-scala-js.md new file mode 100644 index 0000000..526b5d1 --- /dev/null +++ b/wiki/scala-js/capture-checking-on-scala-js.md @@ -0,0 +1,55 @@ +# Capture Checking on Scala.js + +> Sources: Empirical probe (/tmp/cc-js-probe, dapr4s nightly 3.10.0-RC1-bin-20260607-dec42ae-NIGHTLY), scala/scala3 Compiler.scala, scala/scala3 issue tracker, scala-cli v1.13.0 release notes, 2026-06-11 +> Raw: [CC on Scala.js probe report](../../raw/scala-js/2026-06-11-cc-on-scalajs-probe.md) +> Updated: 2026-06-11 + +## Overview + +Capture checking works end-to-end on Scala.js with **zero known incompatibilities and zero observed friction**: dapr4s's exact nightly and full flag set compiles, links, runs on Node, and passes munit tests on the JS platform with no warnings. The reason is architectural — CC never reaches the JS backend. + +## Why it works: CC is erased before GenSJSIR + +In `compiler/src/dotty/tools/dotc/Compiler.scala` (scala/scala3 main), `cc.Setup` and `cc.CheckCaptures` live in **`picklerPhases`** (the frontend group), while `backend.sjs.GenSJSIR` is in `backendPhases`. Capture sets are type annotations erased long before SJSIR generation — the JS backend never sees CC artifacts. Consistently, the scala/scala3 issue tracker has **zero** CC × Scala.js issues (searches for "capture checking scala.js"/"captureChecking scalajs" and filtering the `area:capture-checking` label for scalajs: 0 results). + +## Empirical probe: dapr4s's exact nightly + full flag set + +The probe (`/tmp/cc-js-probe`) used `3.10.0-RC1-bin-20260607-dec42ae-NIGHTLY` (resolves for Scala.js: `scala3-library_sjs1_3` nightly from repo.scala-lang.org, `scalajs-library_2.13` from Central) with dapr4s's complete flag set: + +```scala +//> using platform scala-js +//> using jsVersion 1.21.0 +//> using jsEsVersionStr es2017 +//> using options "-language:experimental.captureChecking" "-language:experimental.pureFunctions" +//> using options "-Ycc-verbose" "-Yexplicit-nulls" "-experimental" "-Wconf:any:error" +``` + +Verified, all with **zero diagnostics under `-Wconf:any:error`** (no exclusions, no `@nowarn`): + +- Capability trait extending `scala.caps.ExclusiveCapability`; methods taking `FileSystem ?=> A`; capture-set-annotated function types `(String => Unit)^{fs}`; `@js.native @JSGlobal` facades used from CC code — compile, link, run. +- Per-file `import language.experimental.safe` — accepted on JS. +- `js.async { js.await(p) + 1 }` — compiles and runs (needs `jsEsVersionStr es2017`, see [js.async, JSPI and the Wasm backend](scala-js-async-jspi-wasm.md)). +- munit + upickle tests pass on Node: `Test run finished: 0 failed, 0 ignored, 2 total`. + +## Explicit nulls × js.native facades + +Under `-Yexplicit-nulls`, only **Java-defined** symbols get nullified types. JS facades are **Scala-defined**, so member types are taken verbatim (`String`, not `String | Null`) — hence zero warnings and no friction. The flip side: **the compiler will not protect you from a facade member that returns `null` at runtime — model nullability yourself** by declaring such members `X | Null`. A modeling concern, not a compiler one. + +## Gotcha: `caps.Capability` is sealed (nightly semantics, not JS-specific) + +In current nightlies `trait Foo extends caps.Capability` fails with `Cannot extend sealed trait Capability in a different source file`. User code must extend the classifier subtraits: `scala.caps.ExclusiveCapability`, `SharedCapability`, etc. dapr4s already does (`trait DaprCapability extends scala.caps.ExclusiveCapability`), but any docs/examples extending bare `Capability` break identically on JVM and JS. See [Capability Classifiers](../scala-capture-checking/capability-classifiers.md). + +## Toolchain requirements (none CC-specific) + +- **scala-cli >= 1.13.0**: munit 1.3.0's JS artifacts carry Scala.js 1.21 IR; older scala-cli bundles a 1.20 linker that fails with `IRVersionNotSupportedException` regardless of `//> using jsVersion`. +- **Platform-suffixed dep syntax** `org::name::version` for JS deps — `munit_sjs1_3:1.3.0` and `upickle_sjs1_3:3.3.1` both exist on Central and resolve; the single-colon form silently picks the JVM artifact and dies at link time with `No framework found by Scala.js test bridge`. + +Both detailed in [Cross-Building JVM + Scala.js with Scala CLI](scala-js-cross-building-scala-cli.md). + +## See Also + +- [Capture Checking Overview](../scala-capture-checking/capture-checking-overview.md) — what CC is and how to enable it +- [How to Use Capture Checking](../scala-capture-checking/how-to-use.md) — flags (`-Ycc-verbose` etc.) used in the probe +- [Capability Classifiers](../scala-capture-checking/capability-classifiers.md) — the classifier subtraits to extend instead of sealed `Capability` +- [Cross-Building JVM + Scala.js with Scala CLI](scala-js-cross-building-scala-cli.md) +- [js.async, JSPI and the Wasm backend](scala-js-async-jspi-wasm.md) diff --git a/wiki/scala-js/scala-js-async-jspi-wasm.md b/wiki/scala-js/scala-js-async-jspi-wasm.md new file mode 100644 index 0000000..1b5c1e7 --- /dev/null +++ b/wiki/scala-js/scala-js-async-jspi-wasm.md @@ -0,0 +1,116 @@ +# js.async / js.await, JSPI, and the WebAssembly Backend + +> Sources: scala-js.org release notes (1.17.0–1.21.0) and WebAssembly backend docs, scala-js/scala-js JSPI.scala, chromestatus.com, nodejs/node#60014, un-ts/synckit, typelevel/cats-effect#529, 2026-06-11; munit-on-Wasm+JSPI harness findings from the dapr4s JS integration suite (scala-cli 1.14.0, Node 25.5), 2026-06-12 +> Raw: [sync-looking APIs on Scala.js research report](../../raw/scala-js/2026-06-11-scalajs-async-jspi.md) +> Updated: 2026-06-12 + +## Overview + +On a single-threaded JS engine, blocking on a Promise in-thread is impossible by design (run-to-completion: the continuation that would resolve the Promise can never run while you spin). Scala.js offers exactly one mechanism that genuinely preserves a direct-style blocking-looking API: the **experimental WebAssembly backend + JSPI** with orphan `js.await` — the architectural analogue of virtual-thread parking. dapr4s uses this to keep its synchronous `def get(key): Option[T]` API byte-identical across JVM and JS. + +## js.async / js.await semantics + +- Introduced in **Scala.js 1.19.0** (2025-04-21). `js.async { ... }` returns `js.Promise[A]`; semantics are exactly an immediately-invoked JS async function `(async () => body)()`. `js.await(p: js.Promise[A]): A` resumes when the promise resolves, or throws on rejection. +- **Requires ES2017+ output** — without it linking fails with `Uses an async block with an ECMAScript version older than ES 2017`. scala-cli: `//> using jsEsVersionStr es2017`; sbt: `withESFeatures(_.withESVersion(ESVersion.ES2017))`. +- **Scala versions:** Scala 2.12/2.13 got it with Scala.js 1.19.0; **Scala 3 needs 3.8.0+** (which bundled Scala.js 1.20.1; 3.8.0 had a runtime regression — use 3.8.1+). dapr4s's 3.10.0-RC1 nightly has it. + +**Lexical restriction on the plain JS backend:** `js.await` is only allowed **lexically inside the `js.async` block** — conditional branches, `while`, `try/catch/finally` are fine, but NOT inside any local method, local class, by-name argument, or closure/lambda (so no `for`-comprehensions or `.map(...)` bodies). On plain JS, `js.await` never crosses function boundaries — the same colored-function model as JS itself. + +## Orphan js.await (Wasm + JSPI only) + +An orphan `js.await` is one not lexically inside `js.async`. Enabled by: + +```scala +import scala.scalajs.js.wasm.JSPI.allowOrphanJSAwait // an implicit js.AwaitPermit +``` + +- There is **no linker flag** for this — it's a source-level implicit import; the gate is a link check. +- The scaladoc is explicit: code using it "will then only link when targeting WebAssembly" — on the plain JS backend, orphan awaits are a **link-time error** (a clean failure mode, not a runtime one). +- On Wasm, validation is at **run time**: there must be a dynamically enclosing `js.async` on the call stack **with no JavaScript frame between it and the `js.await`**, otherwise **`WebAssembly.SuspendError`** is thrown. +- This is "the new superpower offered by JSPI" (1.19.0 notes): *as long as you enter into a `js.async` block somewhere, you can synchronously await Promises in any arbitrary function* — deep ordinary Scala call stacks suspend transparently while the event loop keeps running. + +**The no-intervening-JS-frame rule in practice:** any Scala lambda invoked *by* a JS API (`Promise.then`, timers, `js.Array.map`, an express/HTTP-server handler) is a JS frame on the stack. Awaiting below it throws `SuspendError`. The pattern: **re-enter `js.async { ... }` inside every JS-invoked callback** — each inbound dispatch gets its own async scope, like one virtual thread per request. + +## The WebAssembly backend + +- **Experimental since Scala.js 1.17.0** (2024-09-28); still experimental as of 1.21.0 (docs warn it may be removed in future minor versions and may require newer Wasm engines in minors). Enable: `withExperimentalUseWebAssembly(true)` (sbt) or `//> using jsEmitWasm true` (scala-cli, since v1.5.2). +- **ESModule-only** (`ModuleKind.ESModule` mandatory) and **single module** (`ModuleSplitStyle.FewestModules`; no `js.dynamicImport`). Requires a JS host (not standalone WASI). +- Semantics identical to Scala.js-on-JS; **same javalib/JDK coverage** — no extra unsupported-API list. +- **`@JSExport`/`@JSExportAll` are silently ignored** (JS can't call methods on Scala instances; no `toString` interop). `@JSExportTopLevel` works. +- Performance: ~**30% lower run time** than the JS output for compute-heavy code (interop-heavy can be slower); **code size ~2× the JS backend** in fullLink mode. 1.19.0 added JSPI; 1.20.1 improved Wasm debug info and perf; 1.18.0/1.20.0 were broken and never announced (use 1.18.1/1.20.1). +- `sbt test` works under the Wasm backend (munit runs over the standard test bridge). Published artifacts are **backend-neutral `.sjsir`** — one `_sjs1_3` artifact serves both backends; orphan-await code simply fails to link for plain-JS consumers. + +## JSPI runtime support matrix + +JSPI reached W3C Wasm CG **stage 4 (standardized) April 2025**. + +| Runtime | JSPI | +|---|---| +| Chrome 137+ (May 2025) | shipped by default ("full support" per Scala.js docs) | +| Firefox | flag `javascript.options.wasm_js_promise_integration` per Scala.js docs (default-on possibly recent — uncertain) | +| **Safari** | **none at all** (18.4+ runs the Wasm backend but not js.async/orphan-await code) | +| Node 22 | no JSPI (needs `--experimental-wasm-exnref` just for the backend) | +| Node 23/24 | behind `--experimental-wasm-jspi` | +| **Node 25+** (2025-10, V8 14.1) | **enabled by default** | + +CI note: Node 23/24 flags must be argv flags on the node process (`NODE_OPTIONS` does **not** accept V8 `--experimental-wasm-*` flags); sbt's `NodeJSEnv.Config().withArgs(...)` can pass them, but **scala-cli has no documented way to pass node argv flags to its run/test process** — so Wasm testing under scala-cli is only realistic on **Node 25+**. + +## Rejected alternatives for sync-looking APIs on plain JS + +1. **`Atomics.wait` sync bridge (synckit pattern)** — worker_threads + SharedArrayBuffer + `Atomics.wait` + `receiveMessageOnPort`. Genuinely works in Node (powers eslint-plugin-prettier, Jest tooling), pure-JS deps. Rejected for dapr4s because it **hard-blocks the entire Node event loop per call** (server concurrency collapses to serial) and creates a **deadlock class** with Dapr's bidirectional model: the sidecar calls back into the app (pubsub/actors/bindings/workflow signals) — if a blocked outbound call's completion depends on the blocked main thread serving an inbound request, you deadlock. Also: structured-clone-only payloads, no streaming, Node-only. Viable only for a narrow outbound-only client subset. +2. **deasync** (native addon pumping libuv) — fragile across Node versions, arbitrary reentrancy, ~100× slower than native. **Not shippable** as a library dependency. +3. **Busy-wait/microtask draining** — no such primitive exists in JS. +4. **Async-on-JS API fork** (the cats-effect/sttp precedent: `unsafeRunSync` throws on JS; sync backends are JVM-only) — the ecosystem-standard answer, but rejected for dapr4s: it contradicts the documented "no async/Future-based API" constraint and would fork the entire derivation layer. + +## How dapr4s uses this + +- Public API stays **byte-identical** on both platforms — synchronous direct style; the derivation layer generates synchronous calls unchanged. +- On JS, every capability impl bridges the [Dapr JS SDK](../dapr/dapr-js-sdk.md)'s `js.Promise` via **orphan `js.await`**, confined to a single `JsAwait.await[A](p: js.Promise[A]): A` helper that hosts the `allowOrphanJSAwait` import. JSPI suspension ↔ virtual-thread parking (`CompletableFuture.get()` on the JVM). +- The user enters `js.async { ... }` once at the program edge (`js.async { Dapr().run { ... } }`); JS-only conveniences `runAsync`/`serveAsync` return `js.Promise`. +- Every inbound dispatch (express/SDK callback → Scala) re-enters `js.async` per request, so handlers can suspend freely. +- Consequences for JS consumers: link with `jsEmitWasm true` + `jsModuleKind es` + `jsEsVersionStr es2017`, run on Node 25+ (or 23/24 + flag). Pure parts (models, derivation, validation) still link on the plain JS backend; touching capability impls there is a link-time error. + +## Field notes from the dapr4s port + +Runtime-verified findings from implementing the dapr4s JS internal layer (`src/js/internal/`, scaladocs there are the canonical record). + +### express interop: `JSImport.Default` for CJS default-export modules + +express is a classic CJS module: `module.exports = createApplication` — a *callable function* carrying the middleware factories (`text`, `json`, …) as properties. `JSImport.Default` is the **one binding that yields the callable under both module kinds** (verified at runtime under both): + +- `jsModuleKind commonjs`: Scala.js resolves `Default` through its `$moduleDefault` helper (`m.__esModule ? m.default : m`); express sets no `__esModule` flag → you get `module.exports` itself. +- `jsModuleKind es` (the Wasm/JSPI production target): `import { default as e } from "express"` binds Node's CJS↔ESM interop default — again `module.exports`. + +`JSImport.Namespace` breaks under ES modules: an `import * as ns` namespace object is **never callable**, so `express()` throws `TypeError: ns is not a function`. Rule of thumb: facade any `module.exports = ` module with `JSImport.Default`, never `Namespace`, if it must work under both module kinds. + +### Per-request `js.async` re-entry in express handlers + +An express route handler runs below a JS frame (the router), so capability calls inside it would hit `SuspendError`. The pattern: the handler body immediately enters `js.async { ... }` and lets the resulting promise carry the request — one suspension scope per request, the JS twin of the JVM server's virtual-thread-per-request executor. Same applies to SDK callbacks (workflow activity executors return `js.async(...)` promises the SDK awaits; a rejection becomes the activity's failure, so no catch is wanted). + +### AsyncGenerator from a coroutine (when you can't write `async function*`) + +Recipe (dapr4s `WorkflowCoroutine`, driving the Dapr JS SDK's orchestration executor): + +- A non-native `js.Object` class with `def next(v)`/`` def `throw`(e) ``/`` def `return`(v) `` returning `js.Promise[{value, done}]`, plus `@JSName(js.Symbol.asyncIterator) def asyncIterator() = this` for the duck-typing check. +- The synchronous body runs in its own `js.async` fiber; "yield" = resolve the pending *step* promise (the one the driver is awaiting from `next()`) with `{value, done: false}`, then orphan-await a fresh *resume* promise that the driver's next `next(v)`/`throw(e)` settles. Register the resume resolver **before** answering the step, so even a synchronous follow-up `next(v)` finds it. +- **Strict-alternation safety argument**: a driver that awaits every `next()`/`throw()` before issuing the next one (the durabletask executor does — verified in `runtime-orchestration-context.js`) guarantees the driver and the fiber strictly alternate; at any instant at most one side is runnable. With JS single-threadedness (JSPI resumes a suspended stack as a promise reaction, never concurrently), plain unsynchronized `var`s for the two resolver pairs are correct: each is written in one phase and consumed-and-cleared in the other. Make the "driver violated the contract" branches throw loudly rather than trying to support concurrent driving. +- A finished generator must answer post-completion `next()` with `{done: true}` (standard protocol — and the executor really does call it). An abandoned fiber (driver stops calling `next`) stays suspended forever and is simply collected — abandoned JSPI stacks are GC-able by design, but note `finally` blocks around the abandoned await never run. + +### Running munit suites on Wasm+JSPI with `scala-cli test` + +Wasm+JSPI integration tests under scala-cli genuinely work (dapr4s runs 8 suites / 26 tests against a live daprd this way): `scala-cli test --power --js --js-emit-wasm --js-module-kind es . --test-only '...'`. Field findings, all empirically hit: + +- **Async test pattern**: each test body is `js.async { }.toFuture` — munit awaits `Future`-returning tests. **Footgun: a raw `js.Promise` return is NOT awaited** — munit treats it as an opaque value and the test passes vacuously before the body has run. Always `.toFuture`. +- **Node flags**: scala-cli runs whatever `node` is first on PATH with **zero V8 flags** (no documented way to pass argv flags to the test runner), so JSPI needs **Node 25+** where it is default-on — Node 23/24's `--experimental-wasm-jspi` cannot be injected (`NODE_OPTIONS` rejects V8 `--experimental-wasm-*` flags). +- **The wasm cleanup bug**: scala-cli 1.14.0 **always exits 1 after a Wasm test run, even when all tests pass** — its cleanup calls `Files.deleteIfExists` on the linked output `/tmp/mainXXXX.mjs`, which for Wasm is a non-empty *directory* (`__loader.js` + `main.js` + `main.wasm`) → `DirectoryNotEmptyException` (`Run.scala:728`). Wrapper pattern (dapr4s `scripts/wasm-test.sh`): exit 0 only when scala-cli exited 0, or when the log shows exactly that exception with every suite reporting "0 failed" and no incomplete runs. +- **The plain-JS linker WEDGES on orphan-await test sources**: if test sources contain orphan `js.await` and you run plain `scala-cli test --js .` (no `--js-emit-wasm`), the linker does not report the expected orphan-await error — it **hangs indefinitely**. Keep Wasm-only suites in a directory you `--exclude` from plain-backend test legs. +- **`--test-only` is ineffective on the JS test runner**: the filter does not actually restrict which suites execute — everything linked runs (the JVM runner honours it). Harmless when the extra suites are fast and environment-free; rely on `--exclude` (source-level) for hard exclusion. +- **npm deps in test runs need an ESM resolution hook**: scala-cli links the test module into a temp dir (`/tmp/...`) and runs Node there; ESM resolution of bare specifiers walks up from the *importing module's own path* — `NODE_PATH` and the CWD are ignored for ES modules — so `import '@dapr/dapr'` cannot reach the project's `node_modules`. Fix: `NODE_OPTIONS="--import /abs/path/hook.mjs"` where the hook calls `node:module`'s `register()` on a *separate* delegate file (hooks run on a dedicated loader thread; self-registration recurses) that retries failed bare-specifier resolutions with the repo root as parent (dapr4s: `scripts/js-it/node-resolve-hook.mjs` + `node-resolve-delegate.mjs`). +- **`java.util.UUID.randomUUID()` does not link on Scala.js** — it reaches for `java.security.SecureRandom`, which the javalib does not provide; the failure is at link time, on both backends. Use a time+`js.Math.random()` scheme for test ids (or `crypto.randomUUID` via a facade if real UUIDs are needed). + +## See Also + +- [Cross-Building JVM + Scala.js with Scala CLI](scala-js-cross-building-scala-cli.md) — build/test/publish mechanics, `jsEmitWasm` directive +- [Capture Checking on Scala.js](capture-checking-on-scala-js.md) — CC composes cleanly with js.async/js.await +- [Dapr JS SDK](../dapr/dapr-js-sdk.md) — the Promise-returning API being awaited +- [Dapr Java SDK — Virtual Threads](../dapr/dapr-java-sdk-virtual-threads.md) — the JVM-side blocking model this mirrors diff --git a/wiki/scala-js/scala-js-cross-building-scala-cli.md b/wiki/scala-js/scala-js-cross-building-scala-cli.md new file mode 100644 index 0000000..70f9a70 --- /dev/null +++ b/wiki/scala-js/scala-js-cross-building-scala-cli.md @@ -0,0 +1,95 @@ +# Cross-Building JVM + Scala.js with Scala CLI + +> Sources: Empirical probes (scala-cli 1.12.2 / 1.14.0, Node 22), scala-cli.virtuslab.org docs, VirtusLab/scala-cli releases & issues, Maven Central artifact probes, 2026-06-11; dep-scoping correction + deps-file pattern verified in the dapr4s cross-build restructure, 2026-06-12 +> Raw: [scala-cli cross-platform probe report](../../raw/scala-js/2026-06-11-scala-cli-crossplatform.md) +> Updated: 2026-06-12 + +## Overview + +Scala CLI (v1.x) can cross-compile and cross-publish a JVM + Scala.js library from a **single source tree** — no sbt-crossproject needed. Everything below was verified empirically (compile/test/publish-local on both platforms, jar/POM inspection), including with dapr4s's exact Scala 3.10.0-RC1 nightly. The two hard constraints are the **`test.dep` platform leak** (plain `using dep` *is* platform-scopable — see below) and the **scala-cli >= 1.13.0 floor** for Scala.js 1.21 IR. + +## The platform directive: first entry = default + +```scala +//> using platform jvm scala-js // also: //> using platforms (plural alias) +``` + +Grammar: `//> using platform (jvm|scala-js|js|scala-native|native)+`. This directive is **not** experimental (unlike `target.platform` and `publish.*`). + +- Plain `scala-cli compile/test/run .` builds **only ONE platform: the first one listed**. The list order is the default-platform choice. +- Select the other platform per invocation: `scala-cli test --js .` (or `--platform js`). +- `--cross` (requires `--power`) runs the command against **all** declared platforms in one invocation — verified for `compile --cross` and `test --cross` (munit suites run once per platform). Earlier issues #3590/#3591 (`run`/`package --cross` only compiling) are fixed. + +## Per-file platform targeting (no directory convention) + +```scala +//> using target.platform jvm // file compiled only for JVM +//> using target.platform scala-js // file compiled only for Scala.js +``` + +- The only directive class that applies **per-file** rather than build-wide (a "require" directive). Marked **experimental** — a warning per use; suppress with `--suppress-experimental-feature-warning` or `scala-cli config suppress-warning.experimental-features true`. +- **There is NO `jvm/`/`js/` directory convention** (issue #1632). You can organize files into platform subdirectories for readability, but every platform-specific file must carry its own `target.platform` directive. +- Verified: jar contents are correctly platform-split after packaging/publishing (JS jar = `.sjsir` + shared/js-only classes, no jvm-only classes; vice versa for JVM). + +## Platform-scoping dependencies: `target.platform` deps files (and the `test.dep` leak) + +**CORRECTION (2026-06-12, supersedes the earlier "dependency directives leak" claim):** a plain `//> using dep` directive written in a file that carries a `//> using target.platform` directive **IS scoped to that platform** — `scala-cli compile|test --js .` simply never resolves a dep declared in a jvm-tagged file, and the published `_sjs1_3` POM stays clean. The earlier verified failure was specific to **`using test.dep`, which is NOT platform-scoped**: a `test.dep` in a jvm-tagged file still leaks into the JS *test* build (the original 404 repro used `test.dep`; the conclusion was over-generalised to all dep directives). + +**The pattern (dapr4s, replacing its older `--exclude jvm-deps.scala` mechanism):** dedicated per-platform deps files at the repo root, each starting with a `target.platform` directive — no `--exclude` flags anywhere: + +- `jvm-deps.scala` — `target.platform "jvm"` + plain `using dep` lines (Dapr Java SDK). +- `js-deps.scala` — `target.platform "scala-js"` + plain `using dep` lines (ScalablyTyped facades). +- `jvm-test-deps.test.scala` — `target.platform "jvm"` + plain `using dep` lines, with **test scope coming from the `.test.scala` filename suffix**. This is the workaround for the `test.dep` leak: never write `test.dep` for a platform-specific dependency; put a plain `dep` in a platform-tagged `*.test.scala` file instead. + +Bonus: single-shot `test --cross` works again with this pattern as far as *dependencies* are concerned — though dapr4s itself still cannot use it: its Wasm-only orphan-await test suites must stay excluded from the plain-JS leg (see the next section), and its JVM integration suites need Docker. + +## `--exclude` has no inverse + +If you do exclude files (`--exclude path`), note there is **no `--include` flag**, and naming an excluded file as a positional argument is **silently ignored** — you cannot re-include per invocation. Excludes are only subtractive; design the build so excludes are rare (dapr4s's sole remaining one hides Wasm-only orphan-await test sources from the plain-JS linker, which would otherwise hang — see [the JSPI article](scala-js-async-jspi-wasm.md)). + +## Dependency syntax: `::` before the version for cross deps + +`org::name:version` (single colon before version) resolves the **JVM** artifact (`munit_3`) even on the JS platform — compilation still succeeds (TASTy is present) but linking dies with the misleading `No framework found by Scala.js test bridge` (no `.sjsir` in the jar). Use the platform-suffixed form **`org::name::version`** (→ `munit_sjs1_3`); it also resolves correctly on JVM, so it's safe unconditionally in a cross-build. + +## Version floor: scala-cli >= 1.13.0 for Scala.js 1.21 IR + +The Scala.js linker is **bundled per scala-cli release** (1.12.x → Scala.js 1.20.2; **1.13.0+ → 1.21.0**) and `//> using jsVersion` **cannot raise the linker's IR ceiling** — verified: `jsVersion 1.21.0` on scala-cli 1.12.2 still fails with `IRVersionNotSupportedException: ... compiled with Scala.js 1.21 (supported up to: 1.20)`. munit 1.3.0's JS artifact pulls `scalajs-library_2.13:1.21.0` (IR 1.21), so **any JS build using munit 1.3.0 requires scala-cli >= 1.13.0**. CI's `scala-cli-setup` installs latest, so only local installs are affected. + +Scala 3 nightlies work on JS: dapr4s's pinned `3.10.0-RC1-bin-20260607-dec42ae-NIGHTLY` compiled for Scala.js 1.21.0 and passed munit 1.3.0 + upickle 3.3.1 tests on Node (`scala3-library_sjs1_3` nightlies live on repo.scala-lang.org; `scalajs-library_2.13` is Scala-3-version-agnostic). + +## Publishing both platforms + +``` +scala-cli --power publish --cross . +→ io.github.example:probe_3:0.1.0 (JVM) +→ io.github.example:probe_sjs1_3:0.1.0 (Scala.js) +``` + +- Without `--cross`, `publish` publishes **only the first/default platform**. Two separate invocations (`publish .` then `publish --js .`) are an equivalent alternative (and the one dapr4s uses — with `target.platform`-scoped deps files, each invocation resolves exactly its own platform's deps). +- Per-platform POMs verified correct: `_sjs1_3` POM depends on `scalajs-library_2.13`, `scala3-library_sjs1_3`, `upickle_sjs1_3`; the `_3` POM on the JVM artifacts. Both modules get jar + sources + javadoc + POM. +- `publish.computeVersion git:dynver` works for cross publish; `publish.repository central` goes via the Central Portal OSSRH Staging API since scala-cli 1.8.4. `--cross` is undocumented in the publish docs (only `--help-full`); remote Central staging of two modules in one `--cross` run is untested — verify on first release. +- `publish local` does not support publishing the test scope. + +## Scala.js directives & test runtime + +```scala +//> using jsVersion 1.21.0 // cannot exceed the bundled linker +//> using jsModuleKind es // commonjs/common, es/esmodule, none/nomodule +//> using jsEsVersionStr es2017 // required for js.async/js.await +//> using jsDom true // JSDOMNodeJSEnv (still Node) +//> using jsEmitWasm true // experimental Wasm backend (since scala-cli 1.5.2); needs jsModuleKind es +``` + +- `test --js` runs on **Node.js** (plain `NodeJSEnv`) by default. +- **npm module resolution is cwd-based**: scala-cli feeds the launcher to node via **stdin** (`requireStack: [ '/tmp/[stdin]' ]`), so `require()` resolves against `node_modules` in the directory **you invoke scala-cli from** — not the project-dir argument, not the output dir. Run scala-cli from the directory containing `node_modules`, or set `NODE_PATH=/path/to/node_modules` (CommonJS only, not ES modules). scala-cli has no bundler integration. + +## GitHub Actions + +Canonical job: `actions/checkout` (with `fetch-depth: 0` for `git:dynver`) + `coursier/cache-action` + `VirtusLab/scala-cli-setup` (`with: power: true`). For JS: add `actions/setup-node@v4` if you need a specific Node version (Node 25+ recommended for Wasm/JSPI, see [js.async, JSPI and the Wasm backend](scala-js-async-jspi-wasm.md)); JS test step is `scala-cli test --js .` — npm setup only needed for `--js-dom` or runtime `require()` of npm packages (install in the step's cwd). Publish: gate the JS publish invocation on the JS test job. + +## See Also + +- [js.async, JSPI and the Wasm backend](scala-js-async-jspi-wasm.md) — the directives/Node versions needed for direct-style code on JS, plus testing-under-scala-cli field notes +- [ScalablyTyped Facades with Scala CLI](scalablytyped-with-scala-cli.md) — generating the npm-package facades the `js-deps.scala` pattern pins +- [Capture Checking on Scala.js](capture-checking-on-scala-js.md) — the same toolchain floor applies; CC adds zero extra constraints +- [Dapr JS SDK](../dapr/dapr-js-sdk.md) — the npm dependency dapr4s's JS platform binds to (cwd-based resolution applies) diff --git a/wiki/scala-js/scalablytyped-with-scala-cli.md b/wiki/scala-js/scalablytyped-with-scala-cli.md new file mode 100644 index 0000000..cb66352 --- /dev/null +++ b/wiki/scala-js/scalablytyped-with-scala-cli.md @@ -0,0 +1,73 @@ +# ScalablyTyped Facades with Scala CLI + +> Sources: empirical dapr4s ScalablyTyped migration — converter CLI 1.0.0-beta45 runs over @dapr/dapr 3.18.0 / @types/express 4.17.21 / @types/node 22.13.0, runtime-verified against a live daprd sidecar; scripts/generate-st-facades.sh, js-deps.scala, package.json, src/js/internal/ in the dapr4s repo, 2026-06-12 +> Raw: none — the verified findings live in the dapr4s repo itself (the script headers, js-deps.scala comments, and ExpressModule.scala scaladoc are the canonical record) +> Updated: 2026-06-12 + +## Overview + +ScalablyTyped (ST) converts TypeScript type definitions into Scala.js facades. Its sbt plugin is the documented path; with **Scala CLI there is no plugin**, but the **converter CLI** works fine as a one-shot generation step: it reads `package.json`/`package-lock.json`/`node_modules` from the working directory, converts every top-level dependency that ships (or has `@types/*`) typings, and **publishes the facade jars to the local ivy repository** (`~/.ivy2/local/org.scalablytyped/...`), which scala-cli resolves with zero configuration. dapr4s replaced its entire hand-written `@dapr/dapr`/express/Node facade layer with this (one shim survives — see below). + +```bash +cs launch "org.scalablytyped.converter:cli_3:1.0.0-beta45" -- \ + --scala 3.3.6 --scalajs 1.21.0 -s es2022 --outputPackage dapr4styped +``` + +Then depend on the printed coordinates: `//> using compileOnly.dep "org.scalablytyped::dapr__dapr::3.18.0-d3e034"` (in a `target.platform "scala-js"`-scoped deps file — see [Cross-Building JVM + Scala.js with Scala CLI](scala-js-cross-building-scala-cli.md); compileOnly because the classes are embedded into the published jar — see the consumer-problem section below). + +## Flag landmines (all empirically hit) + +- **`--scala 3` is a trap**: it resolves to the *latest* Scala 3 (3.7.3 at the time), under which the converter's `std` (TS stdlib) conversion breaks. **Pin `--scala 3.3.6`.** This does not constrain the consuming build: ST publishes `_sjs1_3` jars, which any Scala 3 compiler (including dapr4s's 3.10 nightly) consumes as ordinary TASTy-bearing dependencies. +- **`--scalajs` needs a full version** (`1.21.0`), not a prefix. +- **`-s es2022`** selects which TS stdlib pieces to convert; the default set includes the **`dom` stdlib, which fails to convert** — `es2022` skips it (fine for Node-only targets). +- **The `typescript` npm package must be installed** in the working directory (the converter drives the TS compiler API). Keep it in `devDependencies` — it is a tool, not a conversion root. +- The converter drops its generated `.scala` sources into `./out` of the working directory. **Delete that scratch tree** after the run (scala-cli would otherwise compile it as project sources); the ivy2Local jars are the only output that matters. + +## Conversion roots: `@types/*` must be top-level *dependencies* + +The converter converts the packages in `dependencies` — `devDependencies` are skipped. So `@types/express` and `@types/node` go into **`dependencies`** even though at runtime they are type-only. Transitive type-level deps get converted and published too (dapr4s's three roots pull in ~a dozen `org.scalablytyped` jars; you only declare the roots). + +## Deterministic digests — the reproducibility contract + +Each published coordinate is `org.scalablytyped::::-` (e.g. `3.18.0-d1e27c`). The digest is **deterministic in exactly three inputs**: the `package-lock.json` contents, the converter version, and the converter flags. Consequences: + +- **Commit `package-lock.json`** — it is what makes every machine produce identical coordinates. +- Pin the converter tuple (version + flags) and the expected digests in one script; make the script **fail loudly** if the deps file and the script's pins drift apart (dapr4s: `scripts/generate-st-facades.sh` greps `js-deps.scala` for its `EXPECTED_*` values before doing anything). +- Make the script **idempotent**: check for a marker jar and exit 0 — re-runs become instant, so "run the script unconditionally" is a valid CI step. +- CI caching: cache `~/.ivy2/local/org.scalablytyped` + `~/.cache/scalablytyped` keyed on the converter tuple + `hashFiles('package-lock.json')`. + +Updating a pinned npm version: bump `package.json`, `npm install`, rerun the converter, copy the printed coordinates into both the deps file and the script pins. + +## ESM gotchas (the Wasm/JSPI production target is ESM-only) + +- **Never reference a deep-module ST object in value position.** ST models deep modules (e.g. `@dapr/dapr/enum/HttpMethod.enum`) with `@JSImport` specifiers that carry no `.js` extension; if the npm package has no `exports` map, Node ESM throws `ERR_MODULE_NOT_FOUND` **at load time**. Deep **types** are fine (erased, no import emitted); for **values** use the root re-exports (`typings..mod.*`), and where no root re-export exists, pin the runtime values by hand with a documented source reference. Compile-green does not catch this — only running under Node ESM does. +- **Callable CJS default exports break ST's entry point.** ST captures a module root as a namespace import, and an ESM `import * as ns` namespace object is **never callable** — so `typings.express.mod.apply()` throws `TypeError: ns is not a function`. The fix is a tiny hand-written `@JSImport("express", JSImport.Default)` shim (callable under both module kinds), typed against the ST-generated types so everything else stays on the generated surface (dapr4s: `src/js/internal/facade/ExpressModule.scala`, which also recovers `express.text` — lost to a `ResolveTypeQueries` converter warning that degrades the member to `Any`). + +## ST API shapes you will meet + +- **`Partial<...>` options objects become builder-style traits**: `PartialDaprClientOptions().setDaprHost(...).setDaprPort(...)` (MutableBuilder setters), not case-class-like constructors. +- **The TS types are erased and occasionally wrong** — treat ST types as the *signatures* and verify *behaviour* against the installed JS sources. Verified examples from `@dapr/dapr`: `SubscribeConfigurationStream.stop()` is typed `void` but returns a Promise; transaction etags are typed as an `IEtag = {value}` object but go on the wire as plain strings. Where type and runtime diverge, keep the runtime behaviour and document the divergence at the cast site. +- The ST jars are **precompiled with their own flags** — your `-Wconf:any:error`/explicit-nulls/CC flags do not apply to them. Under `-Yexplicit-nulls`, ST results must **not** be `.nn`-ed (it is an error: unnecessary `.nn`). + +## The published-library consumer problem — solved by embedding + +`org.scalablytyped` coordinates from the CLI exist **only in ivy2Local — they are not on Maven Central**. If you *publish* a Scala.js library whose POM references them, downstream users cannot resolve them from any remote repository. dapr4s's implemented answer (verified end-to-end by publishing locally, hiding `~/.ivy2/local/org.scalablytyped`, and compiling+linking+running a consumer against the published jar alone) is a three-part embedding scheme: + +1. **Rename the generated package** with `--outputPackage` (dapr4s: `dapr4styped`) — the classes will ship inside your jar, and a consumer running its own ST generation always gets `typings.*` (with its own `typings.std`/`typings.node`), so the default package would collide at link time. The flag is parsed as a **single `Name`** (`Main.scala`: `Name(x)`); a dotted value is backtick-escaped into one bizarre identifier, not a nested package — use one identifier. The flag is digest-relevant like every other flag. +2. **Declare the ST deps as `//> using compileOnly.dep`** (scala-cli >= 1.14 verified): platform-scoped by `target.platform` exactly like `using dep`, on the compile classpath, but **completely absent from the published POM** (not even scope `provided`). Add as *regular* deps the Central-hosted libraries the generated code links against — `com.olvind::scalablytyped-runtime` and `org.scala-js::scalajs-dom` (versions: read them from a generated ivy-local POM) — which previously arrived transitively through the now-unreferenced ST POMs. +3. **Embed the facade classes at publish time**: resolve the exact transitive `org.scalablytyped` jar set of the roots with `cs fetch` from the deps-file pins (never glob the ivy directory — it accumulates stale digests), unpack only `*.class`/`*.tasty`/`*.sjsir` (no META-INF) into a staging dir, and publish with `scala-cli publish --js . --resource-dirs ` — resource dirs land in the jar as-is. (dapr4s: `scripts/embed-st-facades.sh` → `.scala-build/st-embed`.) + +The result: the published `_sjs1_3` jar contains your own sjsir plus the renamed facade tree, the POM references Maven Central only, and consumers compile/link/run with zero ST involvement. Generation remains a build-time prerequisite for the library repo itself. + +Alternatives considered: + +- **Ship the generation recipe** (dapr4s's previous choice): commit `package.json` + `package-lock.json` + the pinned generation script; consumers run the script once and — thanks to digest determinism — materialise *exactly* the coordinates the POM references in their own ivy2Local. Works, but every consumer pays the converter toll and CI complexity. +- Republish the facades under your own organisation to a real repository (heavier: you own ~a dozen artifacts and their upgrade cadence; the sbt-plugin world solves this with a private Maven repo). +- Vendor the generated sources into your repo (rejected for dapr4s: hundreds of thousands of generated lines, unreviewable diffs). + +## See Also + +- [Cross-Building JVM + Scala.js with Scala CLI](scala-js-cross-building-scala-cli.md) — the `target.platform`-scoped deps files the facade coordinates live in +- [js.async, JSPI and the Wasm backend](scala-js-async-jspi-wasm.md) — the ESM-only Wasm target these facades must load under +- [Capture Checking on Scala.js](capture-checking-on-scala-js.md) — explicit nulls × facades; CC does not apply to the precompiled ST jars +- [Dapr JS SDK](../dapr/dapr-js-sdk.md) — the runtime behaviour behind the `typings.daprDapr` types (wire formats, soft failures, executor driving rules) diff --git a/wiki/scala3-language/java-interop-safe-scala.md b/wiki/scala3-language/java-interop-safe-scala.md index 8b7a15b..1f885a1 100644 --- a/wiki/scala3-language/java-interop-safe-scala.md +++ b/wiki/scala3-language/java-interop-safe-scala.md @@ -184,4 +184,4 @@ The `Ox ?=> Unit` signature of `runAsync` ensures the processing body has a mana - [Context Functions and Capability Passing](context-functions-capability-passing.md) - [Safe Mode](../scala-capture-checking/safe-mode.md) - [Capabilities for Safe Agents](../capabilities-research/capabilities-for-safe-agents.md) -- [Scala CLI Build Tool](scala-cli-build-tool.md) +- [Cross-building with Scala CLI](../scala-js/scala-js-cross-building-scala-cli.md)