From 226d3f64cf7618add84a3f796d7b322f5d8b633b Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Thu, 11 Jun 2026 01:53:48 +0200 Subject: [PATCH 01/17] build: cross-platform plumbing for Scala.js - project.scala: declare platforms jvm + scala-js (jvm default), jsEsVersionStr es2017 (required by js.async/js.await), scala-java-time (java.time on JS) - jvm-deps.scala: JVM-only deps (Dapr Java SDK, testcontainers) split out so JS invocations can --exclude them (scala-cli cannot platform-scope dep directives) - tag src/internal/**, src/jvm/Dapr.scala (moved from src/Dapr.scala) and JVM-bound tests with //> using target.platform jvm - SidecarConfig TLS material: java.nio.file.Path -> new PemPath opaque type (java.nio.file does not exist on Scala.js) - package.json: pin @dapr/dapr 3.18.0 for the upcoming JS internal layer Co-Authored-By: Claude Fable 5 --- .gitignore | 3 +- jvm-deps.scala | 24 ++++++++++++++++ package.json | 19 +++++++++++++ project.scala | 28 +++++++++++-------- src/DaprConfig.scala | 10 +++++-- src/internal/ActorCapabilityImpl.scala | 1 + src/internal/BindingsCapabilityImpl.scala | 1 + .../ConfigurationCapabilityImpl.scala | 1 + src/internal/ConversationCapabilityImpl.scala | 1 + src/internal/CryptoCapabilityImpl.scala | 1 + src/internal/DaprAppServer.scala | 1 + src/internal/DaprCapabilityImpl.scala | 1 + src/internal/FluxOps.scala | 1 + src/internal/HttpActorContext.scala | 1 + src/internal/InvokeCapabilityImpl.scala | 1 + src/internal/JobsCapabilityImpl.scala | 1 + src/internal/Json.scala | 1 + src/internal/LockCapabilityImpl.scala | 1 + src/internal/MonoOps.scala | 1 + src/internal/NullOps.scala | 1 + src/internal/PublishCapabilityImpl.scala | 1 + src/internal/SecretsCapabilityImpl.scala | 1 + src/internal/StateCapabilityImpl.scala | 1 + src/internal/WorkflowBridges.scala | 1 + src/internal/WorkflowCapabilityImpl.scala | 1 + src/internal/WorkflowContextImpl.scala | 1 + src/{ => jvm}/Dapr.scala | 13 +++++---- src/optypes/PemPath.scala | 18 ++++++++++++ test/TestCodecs.scala | 1 + test/TestDaprExtensions.scala | 1 + .../ActorCapabilityServerTest.scala | 1 + .../ConversationCapabilityServerTest.scala | 1 + .../CryptoCapabilityServerTest.scala | 1 + test/integration/DaprTestContainer.scala | 1 + .../integration/EndToEndIntegrationTest.scala | 1 + .../InventoryServiceIntegrationTest.scala | 1 + .../InvokeCapabilityServerTest.scala | 1 + test/integration/InvokeIntegrationTest.scala | 1 + .../JobsCapabilityServerTest.scala | 1 + .../LockCapabilityServerTest.scala | 1 + .../OrderServiceIntegrationTest.scala | 1 + test/integration/PubSubIntegrationTest.scala | 1 + .../PublishCapabilityServerTest.scala | 1 + .../SecretsCapabilityServerTest.scala | 1 + test/integration/SecretsIntegrationTest.scala | 1 + .../StateCapabilityServerTest.scala | 1 + test/integration/StateIntegrationTest.scala | 1 + test/integration/TestDaprApp.scala | 1 + .../WorkflowCapabilityServerTest.scala | 1 + test/unit/BindingDispatchTest.scala | 1 + test/unit/DaprServerTestBase.scala | 1 + test/unit/JobDispatchTest.scala | 1 + test/unit/SubscriberTest.scala | 1 + 53 files changed, 140 insertions(+), 21 deletions(-) create mode 100644 jvm-deps.scala create mode 100644 package.json rename src/{ => jvm}/Dapr.scala (97%) create mode 100644 src/optypes/PemPath.scala diff --git a/.gitignore b/.gitignore index 9f72758..3bbb64c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .claude - +node_modules/ +package-lock.json diff --git a/jvm-deps.scala b/jvm-deps.scala new file mode 100644 index 0000000..a42f921 --- /dev/null +++ b/jvm-deps.scala @@ -0,0 +1,24 @@ +// JVM-only dependencies, kept out of project.scala on purpose. +// +// scala-cli cannot scope dependency directives to a platform: a `//> using dep` directive +// applies to every platform of the build no matter which file it appears in (even a file +// tagged `//> using target.platform jvm`). Keeping the Dapr Java SDK and testcontainers here +// and excluding this file from Scala.js invocations is what keeps the published _sjs1_3 POM +// free of JVM-only artifacts. +// +// Default invocations (`scala-cli compile|test|publish .`) include this file, so the JVM +// workflow is unchanged. Every Scala.js invocation must exclude it: +// +// scala-cli compile --js . --exclude jvm-deps.scala +// scala-cli test --js . --exclude jvm-deps.scala +// scala-cli publish --js . --exclude jvm-deps.scala +// +//> 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 "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). diff --git a/package.json b/package.json new file mode 100644 index 0000000..bf4e79d --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "t3code-c916dd05", + "version": "1.0.0", + "description": "| CI | Release | | --- | --- | | [![Build Status][Badge-GitHubActions]][Link-GitHubActions] | [![Release Artifacts][Badge-MavenCentral]][Link-MavenCentral] |", + "main": "index.js", + "directories": { + "doc": "docs", + "test": "test" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@dapr/dapr": "^3.18.0" + } +} diff --git a/project.scala b/project.scala index 625c6ea..9b71359 100644 --- a/project.scala +++ b/project.scala @@ -1,5 +1,7 @@ //> using scala "3.10.0-RC1-bin-20260607-dec42ae-NIGHTLY" +//> using platform "jvm" "scala-js" //> using jvm "zulu:25.0.3" +//> using jsEsVersionStr "es2017" //> using options "-language:experimental.captureChecking" //> using options "-language:experimental.pureFunctions" //> using options "-Ycc-verbose" @@ -9,17 +11,21 @@ // 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` (and add `--exclude jvm-deps.scala`, see below). +// jsEsVersionStr es2017 is required by js.async/js.await (used by the JS internal layer). +// +// JVM-only dependencies (the Dapr Java SDK and testcontainers) live in jvm-deps.scala, NOT +// here: scala-cli has no platform-scoped dependency directives (deps declared in a +// `//> using target.platform jvm` file still leak into the Scala.js build and would pollute +// the published _sjs1_3 POM). JS invocations exclude that file: `--exclude jvm-deps.scala`. +// +// scala-java-time provides java.time on Scala.js (java.time.Instant is part of the public +// JobsCapability/Models API); on the JVM it is a thin shim over the JDK and harmless. +//> using dep "io.github.cquiroz::scala-java-time::2.6.0" +//> 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/src/DaprConfig.scala b/src/DaprConfig.scala index ddcaf75..656b5b4 100644 --- a/src/DaprConfig.scala +++ b/src/DaprConfig.scala @@ -58,6 +58,10 @@ 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`, `apiToken`, `grpcMaxInboundMessageSizeBytes`, and `timeout` are + * honoured by the JS backend (the Dapr JS SDK exposes a much smaller knob set); the OkHttp/gRPC-Java transport + * settings 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 +81,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/internal/ActorCapabilityImpl.scala b/src/internal/ActorCapabilityImpl.scala index 4cf14a7..7fa0736 100644 --- a/src/internal/ActorCapabilityImpl.scala +++ b/src/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/internal/BindingsCapabilityImpl.scala index 3416d32..a00ddae 100644 --- a/src/internal/BindingsCapabilityImpl.scala +++ b/src/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/internal/ConfigurationCapabilityImpl.scala index 3ec8ff1..3a20b8f 100644 --- a/src/internal/ConfigurationCapabilityImpl.scala +++ b/src/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/internal/ConversationCapabilityImpl.scala index 202f150..8437ec0 100644 --- a/src/internal/ConversationCapabilityImpl.scala +++ b/src/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/internal/CryptoCapabilityImpl.scala index ec50966..09662a8 100644 --- a/src/internal/CryptoCapabilityImpl.scala +++ b/src/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/internal/DaprAppServer.scala index aa1958d..3fa6dc5 100644 --- a/src/internal/DaprAppServer.scala +++ b/src/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/internal/DaprCapabilityImpl.scala index a8cce60..322ef27 100644 --- a/src/internal/DaprCapabilityImpl.scala +++ b/src/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/internal/FluxOps.scala index 792e755..f7b6162 100644 --- a/src/internal/FluxOps.scala +++ b/src/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/internal/HttpActorContext.scala index 2099cde..9051b9c 100644 --- a/src/internal/HttpActorContext.scala +++ b/src/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/internal/InvokeCapabilityImpl.scala index 9158636..22f2161 100644 --- a/src/internal/InvokeCapabilityImpl.scala +++ b/src/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/internal/JobsCapabilityImpl.scala index e468977..c69a2f5 100644 --- a/src/internal/JobsCapabilityImpl.scala +++ b/src/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/internal/Json.scala index b584ea2..2edde5d 100644 --- a/src/internal/Json.scala +++ b/src/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/internal/LockCapabilityImpl.scala index 24628fe..e401651 100644 --- a/src/internal/LockCapabilityImpl.scala +++ b/src/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/internal/MonoOps.scala index 2dcb9a3..dd2a1be 100644 --- a/src/internal/MonoOps.scala +++ b/src/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/internal/NullOps.scala index a1e6fc4..445a692 100644 --- a/src/internal/NullOps.scala +++ b/src/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/internal/PublishCapabilityImpl.scala index 893ff8d..d5ef199 100644 --- a/src/internal/PublishCapabilityImpl.scala +++ b/src/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/internal/SecretsCapabilityImpl.scala index 13423dc..743575f 100644 --- a/src/internal/SecretsCapabilityImpl.scala +++ b/src/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/internal/StateCapabilityImpl.scala index edd556a..d323516 100644 --- a/src/internal/StateCapabilityImpl.scala +++ b/src/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/internal/WorkflowBridges.scala index c2bc747..7fcee32 100644 --- a/src/internal/WorkflowBridges.scala +++ b/src/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/internal/WorkflowCapabilityImpl.scala index b449e20..19b4f14 100644 --- a/src/internal/WorkflowCapabilityImpl.scala +++ b/src/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/internal/WorkflowContextImpl.scala index 0292b63..5e1d636 100644 --- a/src/internal/WorkflowContextImpl.scala +++ b/src/internal/WorkflowContextImpl.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.internal import dapr4s.* 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/optypes/PemPath.scala b/src/optypes/PemPath.scala new file mode 100644 index 0000000..ae73a68 --- /dev/null +++ b/src/optypes/PemPath.scala @@ -0,0 +1,18 @@ +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, convert with + * `PemPath(path.toString)` or `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/test/TestCodecs.scala b/test/TestCodecs.scala index 52cb153..97bce1f 100644 --- a/test/TestCodecs.scala +++ b/test/TestCodecs.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s import com.fasterxml.jackson.databind.ObjectMapper diff --git a/test/TestDaprExtensions.scala b/test/TestDaprExtensions.scala index 39d5f9a..7c9763d 100644 --- a/test/TestDaprExtensions.scala +++ b/test/TestDaprExtensions.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s import java.net.URI diff --git a/test/integration/ActorCapabilityServerTest.scala b/test/integration/ActorCapabilityServerTest.scala index 745d732..62d4509 100644 --- a/test/integration/ActorCapabilityServerTest.scala +++ b/test/integration/ActorCapabilityServerTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/integration/ConversationCapabilityServerTest.scala b/test/integration/ConversationCapabilityServerTest.scala index 46468df..216e80a 100644 --- a/test/integration/ConversationCapabilityServerTest.scala +++ b/test/integration/ConversationCapabilityServerTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/integration/CryptoCapabilityServerTest.scala b/test/integration/CryptoCapabilityServerTest.scala index bba8111..f152f66 100644 --- a/test/integration/CryptoCapabilityServerTest.scala +++ b/test/integration/CryptoCapabilityServerTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/integration/DaprTestContainer.scala b/test/integration/DaprTestContainer.scala index c8be979..c43df00 100644 --- a/test/integration/DaprTestContainer.scala +++ b/test/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/integration/EndToEndIntegrationTest.scala index 895a5e8..f851bc9 100644 --- a/test/integration/EndToEndIntegrationTest.scala +++ b/test/integration/EndToEndIntegrationTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/integration/InventoryServiceIntegrationTest.scala b/test/integration/InventoryServiceIntegrationTest.scala index e336c25..6e4750c 100644 --- a/test/integration/InventoryServiceIntegrationTest.scala +++ b/test/integration/InventoryServiceIntegrationTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/integration/InvokeCapabilityServerTest.scala b/test/integration/InvokeCapabilityServerTest.scala index e61e9d9..76dd65c 100644 --- a/test/integration/InvokeCapabilityServerTest.scala +++ b/test/integration/InvokeCapabilityServerTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/integration/InvokeIntegrationTest.scala b/test/integration/InvokeIntegrationTest.scala index 2ddf383..df3bfe7 100644 --- a/test/integration/InvokeIntegrationTest.scala +++ b/test/integration/InvokeIntegrationTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/integration/JobsCapabilityServerTest.scala b/test/integration/JobsCapabilityServerTest.scala index f2dc6e6..9dfc506 100644 --- a/test/integration/JobsCapabilityServerTest.scala +++ b/test/integration/JobsCapabilityServerTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/integration/LockCapabilityServerTest.scala b/test/integration/LockCapabilityServerTest.scala index 4232e31..db5f50e 100644 --- a/test/integration/LockCapabilityServerTest.scala +++ b/test/integration/LockCapabilityServerTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/integration/OrderServiceIntegrationTest.scala b/test/integration/OrderServiceIntegrationTest.scala index c7fadcc..b34fb40 100644 --- a/test/integration/OrderServiceIntegrationTest.scala +++ b/test/integration/OrderServiceIntegrationTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/integration/PubSubIntegrationTest.scala b/test/integration/PubSubIntegrationTest.scala index 7ef80af..18f76e5 100644 --- a/test/integration/PubSubIntegrationTest.scala +++ b/test/integration/PubSubIntegrationTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/integration/PublishCapabilityServerTest.scala b/test/integration/PublishCapabilityServerTest.scala index ebf63d6..3fffae4 100644 --- a/test/integration/PublishCapabilityServerTest.scala +++ b/test/integration/PublishCapabilityServerTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/integration/SecretsCapabilityServerTest.scala b/test/integration/SecretsCapabilityServerTest.scala index 9f24459..c4e4fde 100644 --- a/test/integration/SecretsCapabilityServerTest.scala +++ b/test/integration/SecretsCapabilityServerTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/integration/SecretsIntegrationTest.scala b/test/integration/SecretsIntegrationTest.scala index a7c8d05..c54c60b 100644 --- a/test/integration/SecretsIntegrationTest.scala +++ b/test/integration/SecretsIntegrationTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/integration/StateCapabilityServerTest.scala b/test/integration/StateCapabilityServerTest.scala index cc0de87..6d262cb 100644 --- a/test/integration/StateCapabilityServerTest.scala +++ b/test/integration/StateCapabilityServerTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/integration/StateIntegrationTest.scala b/test/integration/StateIntegrationTest.scala index 26983ed..e07bf88 100644 --- a/test/integration/StateIntegrationTest.scala +++ b/test/integration/StateIntegrationTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/integration/TestDaprApp.scala b/test/integration/TestDaprApp.scala index 405b3da..92ebbe0 100644 --- a/test/integration/TestDaprApp.scala +++ b/test/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/integration/WorkflowCapabilityServerTest.scala index 7699e0e..5ea78e2 100644 --- a/test/integration/WorkflowCapabilityServerTest.scala +++ b/test/integration/WorkflowCapabilityServerTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration import dapr4s.* diff --git a/test/unit/BindingDispatchTest.scala b/test/unit/BindingDispatchTest.scala index 0e11e6b..d24f009 100644 --- a/test/unit/BindingDispatchTest.scala +++ b/test/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/unit/DaprServerTestBase.scala index 4073405..d17a0cd 100644 --- a/test/unit/DaprServerTestBase.scala +++ b/test/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/unit/JobDispatchTest.scala index 331ae6a..9819a89 100644 --- a/test/unit/JobDispatchTest.scala +++ b/test/unit/JobDispatchTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.unit import dapr4s.* diff --git a/test/unit/SubscriberTest.scala b/test/unit/SubscriberTest.scala index 7a03f84..ba8daaf 100644 --- a/test/unit/SubscriberTest.scala +++ b/test/unit/SubscriberTest.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.unit import dapr4s.* From e0c3fdb09d1e2011fba3f0407919f13bbdfc678c Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Thu, 11 Jun 2026 02:28:52 +0200 Subject: [PATCH 02/17] feat: Scala.js client-side internal layer over the Dapr JS SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/internal/js/facade/: @JSImport facades for @dapr/dapr (DaprClient + sub-clients, DaprWorkflowClient, enums, options) and Node-global fetch - src/internal/js/JsAwait.scala: the single orphan-js.await site — JSPI suspends the Wasm stack instead of parking a virtual thread; plain-JS backend fails at link time by design - src/internal/js/*CapabilityImpl.scala: state, publish, invoke, secrets, configuration (gRPC), bindings, lock, crypto (gRPC), actor (raw sidecar HTTP, HttpActorContext precedent), workflow (DaprWorkflowClient); jobs/conversation throw UnsupportedOperationException (absent from the JS SDK) - src/js/Dapr.scala: run/runAsync with the JVM tryClose+suppression teardown; serve throws until the server phase lands - test/TestCodecsJs.scala: ujson-based twin of the Jackson test codecs - wiki: new scala-js topic (cross-building, async/JSPI/Wasm, CC-on-JS) + dapr/dapr-js-sdk.md API map, raw captures, index + log updates Both platforms compile; JVM unit tests stay green (166); scalafmt clean (2 new CC-syntax files added to excludeFilters). Co-Authored-By: Claude Fable 5 --- .scalafmt.conf | 2 + .../2026-06-11-dapr-js-sdk-source-survey.md | 170 +++++++++ .../2026-06-11-cc-on-scalajs-probe.md | 104 ++++++ .../2026-06-11-scala-cli-crossplatform.md | 137 +++++++ raw/scala-js/2026-06-11-scalajs-async-jspi.md | 114 ++++++ src/internal/js/ActorCapabilityImpl.scala | 107 ++++++ src/internal/js/BindingsCapabilityImpl.scala | 43 +++ .../js/ConfigurationCapabilityImpl.scala | 83 +++++ src/internal/js/CryptoCapabilityImpl.scala | 58 +++ src/internal/js/DaprCapabilityImpl.scala | 180 +++++++++ src/internal/js/InvokeCapabilityImpl.scala | 64 ++++ src/internal/js/JsAwait.scala | 47 +++ src/internal/js/JsInterop.scala | 84 +++++ src/internal/js/LockCapabilityImpl.scala | 33 ++ src/internal/js/PublishCapabilityImpl.scala | 65 ++++ src/internal/js/SecretsCapabilityImpl.scala | 81 +++++ src/internal/js/StateCapabilityImpl.scala | 212 +++++++++++ src/internal/js/WorkflowCapabilityImpl.scala | 120 ++++++ src/internal/js/facade/DaprSdk.scala | 342 ++++++++++++++++++ src/internal/js/facade/NodeFetch.scala | 37 ++ src/internal/js/facade/WorkflowSdk.scala | 88 +++++ src/js/Dapr.scala | 133 +++++++ test/TestCodecsJs.scala | 146 ++++++++ .../apps/InventoryServiceMain.scala | 1 + test/integration/apps/OrderServiceMain.scala | 1 + wiki/dapr/dapr-java-sdk.md | 1 + wiki/dapr/dapr-js-sdk.md | 139 +++++++ wiki/index.md | 13 +- wiki/log.md | 8 + wiki/scala-js/capture-checking-on-scala-js.md | 55 +++ wiki/scala-js/scala-js-async-jspi-wasm.md | 78 ++++ .../scala-js-cross-building-scala-cli.md | 84 +++++ 32 files changed, 2829 insertions(+), 1 deletion(-) create mode 100644 raw/dapr/2026-06-11-dapr-js-sdk-source-survey.md create mode 100644 raw/scala-js/2026-06-11-cc-on-scalajs-probe.md create mode 100644 raw/scala-js/2026-06-11-scala-cli-crossplatform.md create mode 100644 raw/scala-js/2026-06-11-scalajs-async-jspi.md create mode 100644 src/internal/js/ActorCapabilityImpl.scala create mode 100644 src/internal/js/BindingsCapabilityImpl.scala create mode 100644 src/internal/js/ConfigurationCapabilityImpl.scala create mode 100644 src/internal/js/CryptoCapabilityImpl.scala create mode 100644 src/internal/js/DaprCapabilityImpl.scala create mode 100644 src/internal/js/InvokeCapabilityImpl.scala create mode 100644 src/internal/js/JsAwait.scala create mode 100644 src/internal/js/JsInterop.scala create mode 100644 src/internal/js/LockCapabilityImpl.scala create mode 100644 src/internal/js/PublishCapabilityImpl.scala create mode 100644 src/internal/js/SecretsCapabilityImpl.scala create mode 100644 src/internal/js/StateCapabilityImpl.scala create mode 100644 src/internal/js/WorkflowCapabilityImpl.scala create mode 100644 src/internal/js/facade/DaprSdk.scala create mode 100644 src/internal/js/facade/NodeFetch.scala create mode 100644 src/internal/js/facade/WorkflowSdk.scala create mode 100644 src/js/Dapr.scala create mode 100644 test/TestCodecsJs.scala create mode 100644 wiki/dapr/dapr-js-sdk.md create mode 100644 wiki/scala-js/capture-checking-on-scala-js.md create mode 100644 wiki/scala-js/scala-js-async-jspi-wasm.md create mode 100644 wiki/scala-js/scala-js-cross-building-scala-cli.md diff --git a/.scalafmt.conf b/.scalafmt.conf index 6f1c9a9..1b5c9ea 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -20,6 +20,8 @@ project.excludeFilters = [ "src/internal/ConfigurationCapabilityImpl\\.scala", "src/internal/DaprCapabilityImpl\\.scala", "src/internal/WorkflowContextImpl\\.scala", + "src/internal/js/ConfigurationCapabilityImpl\\.scala", + "src/internal/js/DaprCapabilityImpl\\.scala", "test/integration/apps/WorkflowApp\\.scala", "test/unit/CapabilityDerivationFixtures\\.scala", "test/unit/WorkflowActivityDerivationFixtures\\.scala", 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/src/internal/js/ActorCapabilityImpl.scala b/src/internal/js/ActorCapabilityImpl.scala new file mode 100644 index 0000000..75d4655 --- /dev/null +++ b/src/internal/js/ActorCapabilityImpl.scala @@ -0,0 +1,107 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.scalajs.js + +/** 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` + + * [[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 = + s"${httpBase(sidecar)}/v1.0/actors/${actorType.value}/${actorId.value}/method/${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: + + /** 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. + */ + private[internal] def baseHeaders(sidecar: SidecarConfig): js.Dictionary[String] = + val headers = js.Dictionary("Content-Type" -> "application/json") + sidecar.apiToken.foreach(t => headers("dapr-api-token") = t.value) + 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 = new facade.FetchRequestInit( + method = "POST", + headers = baseHeaders(sidecar), + body = body.fold[js.UndefOr[String]](js.undefined)(b => b), + ) + val response = JsAwait.await(facade.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/internal/js/BindingsCapabilityImpl.scala b/src/internal/js/BindingsCapabilityImpl.scala new file mode 100644 index 0000000..1b98125 --- /dev/null +++ b/src/internal/js/BindingsCapabilityImpl.scala @@ -0,0 +1,43 @@ +//> 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. + 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/internal/js/ConfigurationCapabilityImpl.scala b/src/internal/js/ConfigurationCapabilityImpl.scala new file mode 100644 index 0000000..d01dc03 --- /dev/null +++ b/src/internal/js/ConfigurationCapabilityImpl.scala @@ -0,0 +1,83 @@ +//> 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.* + +@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[facade.SubscribeConfigurationResponse, js.Promise[Unit]]^{this, onChange} = + (response: facade.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 facade + // signature mirrors the SDK's 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[js.Function1[facade.SubscribeConfigurationResponse, js.Promise[Unit]]], + ), + ) + // stop() is async (it aborts the stream and sends the explicit unsubscribe RPC); awaiting it + // makes close() synchronous like the JVM's `() => sub.dispose()`. + () => JsAwait.await(stream.stop()) + +@scala.caps.assumeSafe +private object ConfigurationCapabilityImpl: + + private def toConfigItem(k: String, item: facade.ConfigurationItemJs): ConfigurationItem = + ConfigurationItem( + key = ConfigurationKey(k), + value = ConfigurationValue(item.value.getOrElse("")), + version = ConfigurationVersion(item.version.getOrElse("")), + metadata = item.metadata.toOption.fold(Map.empty[MetadataKey, MetadataValue]) { jm => + jm.iterator.map { case (mk, mv) => MetadataKey(mk) -> MetadataValue(mv) }.toMap + }, + ) diff --git a/src/internal/js/CryptoCapabilityImpl.scala b/src/internal/js/CryptoCapabilityImpl.scala new file mode 100644 index 0000000..e78cda9 --- /dev/null +++ b/src/internal/js/CryptoCapabilityImpl.scala @@ -0,0 +1,58 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.collection.immutable.ArraySeq +import scala.scalajs.js.typedarray.{Int8Array, Uint8Array} + +@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 = new facade.EncryptRequest( + componentName = componentName.value, + keyName = keyName.value, + keyWrapAlgorithm = algorithm.value, + ) + val result = JsAwait.await(scope.grpcClient.crypto.encrypt(toInt8Array(plaintext), request)) + fromUint8Array(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 = new facade.DecryptRequest(componentName = componentName.value) + val result = JsAwait.await(scope.grpcClient.crypto.decrypt(toInt8Array(ciphertext), request)) + fromUint8Array(result) + +@scala.caps.assumeSafe +private object CryptoCapabilityImpl: + + 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 fromUint8Array(buffer: Uint8Array): 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. + val out = new Array[Byte](buffer.length) + var i = 0 + while i < buffer.length do + out(i) = buffer(i).toByte + i += 1 + ArraySeq.unsafeWrapArray(out) diff --git a/src/internal/js/DaprCapabilityImpl.scala b/src/internal/js/DaprCapabilityImpl.scala new file mode 100644 index 0000000..a088cd0 --- /dev/null +++ b/src/internal/js/DaprCapabilityImpl.scala @@ -0,0 +1,180 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import java.net.URI +import scala.scalajs.js + +/** 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, the individual `*CapabilityImpl` classes, and the + * facades in `dapr4s.internal.facade`. No JS types are visible in the public API. + * + * Lifecycle: [[dapr4s.Dapr.run]] owns all three clients; it creates the HTTP-protocol [[facade.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 [[facade.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: facade.DaprClient, + private[internal] val sidecar: SidecarConfig, + private val grpcClientRef: LazyClientRef[facade.DaprClient], + private val workflowClientRef: LazyClientRef[facade.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: facade.DaprClient = + grpcClientRef.getOrCreate(() => new facade.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 facade.DaprWorkflowClient(workflowClientOptions(sidecar))) + new WorkflowCapabilityImpl(wc).asInstanceOf[WorkflowCapability] + + def crypto(componentName: CryptoComponentName): CryptoCapability^{this} = + new CryptoCapabilityImpl(this, componentName).asInstanceOf[CryptoCapability] + + def jobs: JobsCapability^{this} = + throw new UnsupportedOperationException( + "the Dapr JS SDK (@dapr/dapr 3.x) has no jobs API; use dapr4s on the JVM for the jobs capability", + ) + + def conversation(componentName: ConversationComponentName): ConversationCapability^{this} = + throw new UnsupportedOperationException( + "the Dapr JS SDK (@dapr/dapr 3.x) has no conversation API; use dapr4s on the JVM for the conversation capability", + ) + +@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 the URI scheme is kept + * (`http://host` + port) to preserve TLS/plaintext selection. For gRPC clients the scheme is translated to the + * SDK's preferred `grpc`/`grpcs` (passing `http`/`https` works but triggers a deprecation `console.warn` in + * `GrpcEndpoint.setScheme`). 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" + case "https" => "grpcs" + 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 + (s"$scheme://$host", port.toString) + + private def undefOr[A](o: Option[A]): js.UndefOr[A] = + o.fold[js.UndefOr[A]](js.undefined)(a => a) + + /** 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): facade.DaprClientOptions = + val (host, port) = hostAndPort(sc.httpEndpoint, forGrpc = false) + new facade.DaprClientOptions( + daprHost = host, + daprPort = port, + communicationProtocol = facade.CommunicationProtocolEnum.HTTP, + daprApiToken = undefOr(sc.apiToken.map(_.value)), + maxBodySizeMb = sc.grpcMaxInboundMessageSizeBytes.toDouble / (1024 * 1024), + ) + + /** Options for the lazy gRPC-protocol client, from `SidecarConfig.grpcEndpoint`. */ + private[internal] def grpcClientOptions(sc: SidecarConfig): facade.DaprClientOptions = + val (host, port) = hostAndPort(sc.grpcEndpoint, forGrpc = true) + new facade.DaprClientOptions( + daprHost = host, + daprPort = port, + communicationProtocol = facade.CommunicationProtocolEnum.GRPC, + daprApiToken = undefOr(sc.apiToken.map(_.value)), + maxBodySizeMb = sc.grpcMaxInboundMessageSizeBytes.toDouble / (1024 * 1024), + ) + + /** Options for the lazy workflow client (gRPC, vendored durabletask), from `SidecarConfig.grpcEndpoint`. */ + private[internal] def workflowClientOptions(sc: SidecarConfig): facade.WorkflowClientOptions = + val (host, port) = hostAndPort(sc.grpcEndpoint, forGrpc = true) + new facade.WorkflowClientOptions( + daprHost = host, + daprPort = port, + daprApiToken = undefOr(sc.apiToken.map(_.value)), + ) diff --git a/src/internal/js/InvokeCapabilityImpl.scala b/src/internal/js/InvokeCapabilityImpl.scala new file mode 100644 index 0000000..b18bec4 --- /dev/null +++ b/src/internal/js/InvokeCapabilityImpl.scala @@ -0,0 +1,64 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.scalajs.js +import JsInterop.* + +@scala.caps.assumeSafe +private object InvokeCapabilityImpl: + + /** dapr4s [[HttpMethod]] → the SDK's lowercase `HttpMethod` string values (`enum/HttpMethod.enum.js`). + * + * The SDK enum only declares get/delete/post/put/patch; `"head"` and `"options"` are still correct because the value + * flows verbatim into `HTTPClient.execute`, which upper-cases it and hands it to fetch (`clientOptions.method = + * params?.method.toLocaleUpperCase()`). + */ + private def toJsMethod(m: HttpMethod): String = + m match + case HttpMethod.Get => "get" + case HttpMethod.Post => "post" + case HttpMethod.Put => "put" + case HttpMethod.Delete => "delete" + case HttpMethod.Patch => "patch" + case HttpMethod.Head => "head" + case HttpMethod.Options => "options" + +@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 = + // 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(metadata) + headers("Content-Type") = "application/json" + val response = JsAwait.await( + scope.client.invoker.invoke( + appId.value, + method.value, + toJsMethod(httpMethod), + parseJson(summon[JsonCodec[Req]].encode(data)), + new facade.InvokerOptions(headers = 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, "get", js.undefined, new facade.InvokerOptions()), + ) + JsonCodec.decodeOrThrow[Resp](jsonStringOrNull(response)) diff --git a/src/internal/js/JsAwait.scala b/src/internal/js/JsAwait.scala new file mode 100644 index 0000000..0d5e329 --- /dev/null +++ b/src/internal/js/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/internal/js/JsInterop.scala b/src/internal/js/JsInterop.scala new file mode 100644 index 0000000..ceb5360 --- /dev/null +++ b/src/internal/js/JsInterop.scala @@ -0,0 +1,84 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.scalajs.js + +/** 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) + + /** 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. + */ + 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). + */ + def isAbsent(v: js.Any): Boolean = + js.isUndefined(v) || (v == null) || ((v: Any) match + case s: String => s.isEmpty + case _ => false) + + /** dapr4s metadata map → the `KeyValueType` string dictionary the SDK options take. */ + def toDict(metadata: Map[MetadataKey, MetadataValue]): js.Dictionary[String] = + val d = js.Dictionary.empty[String] + metadata.foreach { case (k, v) => d(k.value) = v.value } + d + + /** 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/internal/js/LockCapabilityImpl.scala b/src/internal/js/LockCapabilityImpl.scala new file mode 100644 index 0000000..6930042 --- /dev/null +++ b/src/internal/js/LockCapabilityImpl.scala @@ -0,0 +1,33 @@ +//> 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.toInt), + ) + // Absent `success` → false, mirroring the JVM's `.toOption.exists(_.booleanValue())` null handling. + response.success.contains(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. + response.status.toOption.fold(UnlockStatus.InternalError) { + case 0 => UnlockStatus.Success + case 1 => UnlockStatus.LockNotFound + case _ => UnlockStatus.InternalError + } diff --git a/src/internal/js/PublishCapabilityImpl.scala b/src/internal/js/PublishCapabilityImpl.scala new file mode 100644 index 0000000..2c832c9 --- /dev/null +++ b/src/internal/js/PublishCapabilityImpl.scala @@ -0,0 +1,65 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.scalajs.js +import scala.scalajs.js.JSConverters.* +import JsInterop.* + +@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"`. + def publish[T: JsonCodec](topic: Topic, data: T): Unit = + val json = summon[JsonCodec[T]].encode(data) + val response = JsAwait.await( + scope.client.pubsub.publish(pubsubName.value, topic.value, parseJson(json), jsonContentTypeOptions), + ) + throwIfFailed(response) + + def publishWithMetadata[T: JsonCodec]( + topic: Topic, + data: T, + metadata: Map[MetadataKey, MetadataValue], + ): Unit = + val json = summon[JsonCodec[T]].encode(data) + val options = new facade.PubSubPublishOptions(contentType = "application/json", metadata = toDict(metadata)) + val response = JsAwait.await(scope.client.pubsub.publish(pubsubName.value, topic.value, parseJson(json), 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) and + // pins application/json per entry for the same scalar-payload reason as publish above. + val messages = entries.map { entry => + new facade.PubSubBulkPublishMessage( + entryID = entry.entryId.value, + event = parseJson(summon[JsonCodec[T]].encode(entry.event)), + contentType = "application/json", + ) + }.toJSArray + val response = JsAwait.await(scope.client.pubsub.publishBulk(pubsubName.value, topic.value, messages)) + val failedIds = response.failedMessages.toList.map(fm => BulkEntryId(fm.message.entryID)) + BulkPublishResult(failedIds) + +@scala.caps.assumeSafe +private object PublishCapabilityImpl: + private val jsonContentTypeOptions = new facade.PubSubPublishOptions(contentType = "application/json") + + /** `pubsub.publish` soft-fails (`{error}` instead of rejecting — `implementation/Client/HTTPClient/pubsub.js`); + * rethrow to mirror the JVM impl, where a failed `publishEvent` throws `DaprException`. + */ + private def throwIfFailed(response: facade.SoftFailureResponse): Unit = + response.error.toOption.foreach(e => throw js.JavaScriptException(e)) diff --git a/src/internal/js/SecretsCapabilityImpl.scala b/src/internal/js/SecretsCapabilityImpl.scala new file mode 100644 index 0000000..03fd38b --- /dev/null +++ b/src/internal/js/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/internal/js/StateCapabilityImpl.scala b/src/internal/js/StateCapabilityImpl.scala new file mode 100644 index 0000000..a0c8090 --- /dev/null +++ b/src/internal/js/StateCapabilityImpl.scala @@ -0,0 +1,212 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.scalajs.js +import scala.scalajs.js.JSConverters.* +import JsInterop.* + +@scala.caps.assumeSafe +private object StateCapabilityImpl: + + /** dapr4s enum → numeric `StateConsistencyEnum` (CONSISTENCY_EVENTUAL = 1, CONSISTENCY_STRONG = 2); `Default` maps to + * `undefined`, which `getStateConsistencyValue` turns into "no query parameter" — the same store-default behaviour + * the JVM impl gets from passing a `null` Java enum. + */ + private def toJsConsistency(c: StateConsistency): js.UndefOr[Int] = + c match + case StateConsistency.Default => js.undefined + case StateConsistency.Eventual => 1 + case StateConsistency.Strong => 2 + + /** dapr4s enum → numeric `StateConcurrencyEnum` (CONCURRENCY_FIRST_WRITE = 1, CONCURRENCY_LAST_WRITE = 2). */ + private def toJsConcurrency(c: StateConcurrency): js.UndefOr[Int] = + c match + case StateConcurrency.Default => js.undefined + case StateConcurrency.FirstWrite => 1 + case StateConcurrency.LastWrite => 2 + + /** 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) + + private def toJsOp(op: StateOp): facade.StateTransactionOperation = + op match + case StateOp.UpsertOp(key, encodedValue, etag) => + new facade.StateTransactionOperation( + operation = "upsert", + request = new facade.StateTransactionRequest( + key = key.value, + value = parseJson(encodedValue.value), + etag = etag.fold[js.UndefOr[String]](js.undefined)(_.value), + ), + ) + case StateOp.DeleteOp(key, etag) => + new facade.StateTransactionOperation( + operation = "delete", + request = new facade.StateTransactionRequest( + key = key.value, + etag = etag.fold[js.UndefOr[String]](js.undefined)(_.value), + ), + ) + + /** 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`); 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: facade.SoftFailureResponse, + 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, + new facade.StateGetOptions(consistency = toJsConsistency(consistency)), + ) + 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(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`. + val query = consistencyQuery(consistency).fold("")(c => s"?consistency=$c") + val url = s"${ActorCapabilityImpl.httpBase(scope.sidecar)}/v1.0/state/${storeName.value}/${key.value}$query" + val response = JsAwait.await( + facade.NodeGlobals.fetch( + url, + new facade.FetchRequestInit(method = "GET", headers = 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 + val items = JsAwait.await(scope.client.state.getBulk(storeName.value, keys.map(_.value).toJSArray)) + items.map { item => + val raw = item.data.toOption.filterNot(isAbsent) + val etag = item.etag.toOption.map(ETag(_)) + StateStoreKey(item.key) -> StateEntry(raw.map(d => decode[T](js.JSON.stringify(d))), etag) + }.toMap + + def save[T: JsonCodec](key: StateStoreKey, value: T): Unit = + val json = summon[JsonCodec[T]].encode(value) + val entry = new facade.StateKeyValuePair(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) => + new facade.StateKeyValuePair(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 = new facade.StateKeyValuePair( + key = key.value, + value = parseJson(json), + etag = etag.value, + options = new facade.StateOperationOptions( + consistency = toJsConsistency(consistency), + concurrency = toJsConcurrency(concurrency), + ), + ) + val options = new facade.StateSaveOptions(metadata = 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, new facade.StateDeleteOptions())) + 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 = new facade.StateDeleteOptions( + etag = etag.value, + consistency = toJsConsistency(consistency), + concurrency = 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]] = + val response = JsAwait.await(scope.client.state.query(storeName.value, parseJson(query.value))) + response.results.toOption.fold(List.empty[StateEntry[T]]) { items => + items.toList.map { item => + val raw = item.data.toOption.filterNot(isAbsent) + val etag = item.etag.toOption.map(ETag(_)) + StateEntry(raw.map(d => decode[T](js.JSON.stringify(d))), etag) + } + } diff --git a/src/internal/js/WorkflowCapabilityImpl.scala b/src/internal/js/WorkflowCapabilityImpl.scala new file mode 100644 index 0000000..20719f4 --- /dev/null +++ b/src/internal/js/WorkflowCapabilityImpl.scala @@ -0,0 +1,120 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.concurrent.duration.FiniteDuration +import scala.scalajs.js +import JsInterop.parseJson + +@scala.caps.assumeSafe +private[internal] final class WorkflowCapabilityImpl( + private val client: facade.DaprWorkflowClient, +) extends WorkflowCapability: + + import WorkflowCapabilityImpl.* + + def start(name: WorkflowName): WorkflowInstanceId = + WorkflowInstanceId(JsAwait.await(client.scheduleNewWorkflow(name.value, js.undefined, js.undefined))) + + // 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, js.undefined))) + + def startWithId(name: WorkflowName, instanceId: WorkflowInstanceId): WorkflowInstanceId = + WorkflowInstanceId(JsAwait.await(client.scheduleNewWorkflow(name.value, js.undefined, 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. + val state = + try JsAwait.await(client.waitForWorkflowCompletion(instanceId.value, true, timeout.toSeconds.toDouble)) + 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: facade.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. + */ + private def toStatus(rs: Int): WorkflowStatus = + rs match + case facade.WorkflowRuntimeStatus.RUNNING => WorkflowStatus.Running + case facade.WorkflowRuntimeStatus.COMPLETED => WorkflowStatus.Completed + case facade.WorkflowRuntimeStatus.CONTINUED_AS_NEW => WorkflowStatus.ContinuedAsNew + case facade.WorkflowRuntimeStatus.FAILED => WorkflowStatus.Failed + case facade.WorkflowRuntimeStatus.TERMINATED => WorkflowStatus.Terminated + case facade.WorkflowRuntimeStatus.PENDING => WorkflowStatus.Pending + case facade.WorkflowRuntimeStatus.SUSPENDED => WorkflowStatus.Suspended + case _ => WorkflowStatus.Pending diff --git a/src/internal/js/facade/DaprSdk.scala b/src/internal/js/facade/DaprSdk.scala new file mode 100644 index 0000000..858c290 --- /dev/null +++ b/src/internal/js/facade/DaprSdk.scala @@ -0,0 +1,342 @@ +//> using target.platform "scala-js" +package dapr4s.internal.facade + +import scala.scalajs.js +import scala.scalajs.js.annotation.JSImport + +// --------------------------------------------------------------------------- +// Scala.js facades for the `@dapr/dapr` npm package (the Dapr JS SDK, 3.x). +// +// Only the classes/enums re-exported from the package root (`index.js`) carry a +// @JSImport. The sub-client interfaces (`IClientState`, `IClientPubSub`, ...) +// and all `*.type.ts` shapes are TypeScript-only types, erased at runtime — +// they are modelled as structural `@js.native` traits WITHOUT @JSImport +// (request/option shapes we construct ourselves are non-native `js.Object` +// classes, so the fields become plain JS properties). +// +// Signatures were verified against the installed sources in +// node_modules/@dapr/dapr (v3.18.0): implementation/Client/DaprClient.d.ts, +// implementation/Client/HTTPClient/*.js, implementation/Client/GRPCClient/*.js, +// interfaces/Client/*.d.ts, types/**. Gotchas baked in below: +// - `CommunicationProtocolEnum` is numeric with GRPC = 0, HTTP = 1. +// - Ports are STRINGS everywhere (`daprPort: string`). +// - `HttpMethod` values are lowercase strings ("get", "post", ...). +// - Options objects are `Partial<...>` — every field is optional. +// --------------------------------------------------------------------------- + +/** Facade for the root `DaprClient` class (`implementation/Client/DaprClient.ts`). + * + * Only the sub-clients dapr4s needs are declared. `start()` is declared but does not need to be called eagerly: every + * sub-client call goes through `HTTPClient.execute` / `GRPCClient.getClient`, which auto-start the client (awaiting + * sidecar health) on first use. + */ +@js.native +@JSImport("@dapr/dapr", "DaprClient") +private[dapr4s] class DaprClient(options: DaprClientOptions) extends js.Object: + val state: StateClient = js.native + val pubsub: PubSubClient = js.native + val binding: BindingClient = js.native + val invoker: InvokerClient = js.native + val secret: SecretClient = js.native + val configuration: ConfigurationClient = js.native + val lock: LockClient = js.native + val crypto: CryptoClient = js.native + val health: HealthClient = js.native + def start(): js.Promise[Unit] = js.native + def stop(): js.Promise[Unit] = js.native + +/** Facade for `types/DaprClientOptions.ts`. All fields are optional (`Partial` in the SDK ctor). */ +private[dapr4s] final class DaprClientOptions( + val daprHost: js.UndefOr[String] = js.undefined, + val daprPort: js.UndefOr[String] = js.undefined, + val communicationProtocol: js.UndefOr[Int] = js.undefined, + val daprApiToken: js.UndefOr[String] = js.undefined, + val maxBodySizeMb: js.UndefOr[Double] = js.undefined, +) extends js.Object + +/** Facade for `enum/CommunicationProtocol.enum.ts`. Numeric, and `GRPC = 0`, `HTTP = 1` — reading the values off the + * real SDK enum (rather than hardcoding integers) keeps us correct if upstream ever renumbers. + */ +@js.native +@JSImport("@dapr/dapr", "CommunicationProtocolEnum") +private[dapr4s] object CommunicationProtocolEnum extends js.Object: + val GRPC: Int = js.native + val HTTP: Int = js.native + +// --------------------------------------------------------------------------- +// state (interfaces/Client/IClientState.ts, implementation/Client/HTTPClient/state.js) +// --------------------------------------------------------------------------- + +@js.native +private[internal] trait StateClient extends js.Object: + def save(storeName: String, stateObjects: js.Array[StateKeyValuePair]): js.Promise[SoftFailureResponse] = js.native + def save( + storeName: String, + stateObjects: js.Array[StateKeyValuePair], + options: StateSaveOptions, + ): js.Promise[SoftFailureResponse] = js.native + def get(storeName: String, key: String): js.Promise[js.Any] = js.native + def get(storeName: String, key: String, options: StateGetOptions): js.Promise[js.Any] = js.native + def getBulk(storeName: String, keys: js.Array[String]): js.Promise[js.Array[BulkStateItem]] = js.native + def delete(storeName: String, key: String, options: StateDeleteOptions): js.Promise[SoftFailureResponse] = js.native + def transaction(storeName: String, operations: js.Array[StateTransactionOperation]): js.Promise[Unit] = js.native + def query(storeName: String, query: js.Any): js.Promise[StateQueryResponse] = js.native + +/** `KeyValuePairType` (`types/KeyValuePair.type.ts`): one entry of a `state.save` call. */ +private[internal] final class StateKeyValuePair( + val key: String, + val value: js.Any, + val etag: js.UndefOr[String] = js.undefined, + val options: js.UndefOr[StateOperationOptions] = js.undefined, +) extends js.Object + +/** `IStateOptions`: per-entry write behaviour. The SDK maps these numeric enums to the `"eventual"`/`"strong"` and + * `"first-write"`/`"last-write"` strings of the HTTP API via `getStateConsistencyValue`/`getStateConcurrencyValue` + * (`utils/Client.util.js`); unspecified (`undefined`/0) maps to no query parameter at all. + */ +private[internal] final class StateOperationOptions( + val consistency: js.UndefOr[Int] = js.undefined, + val concurrency: js.UndefOr[Int] = js.undefined, +) extends js.Object + +/** `StateSaveOptions` (`types/state/StateSaveOptions.type.ts`): metadata becomes `metadata.*` query parameters. */ +private[internal] final class StateSaveOptions( + val metadata: js.UndefOr[js.Dictionary[String]] = js.undefined, +) extends js.Object + +/** `Partial` (`types/state/StateGetOptions.type.ts`). */ +private[internal] final class StateGetOptions( + val consistency: js.UndefOr[Int] = js.undefined, + val metadata: js.UndefOr[js.Dictionary[String]] = js.undefined, +) extends js.Object + +/** `Partial` (`types/state/StateDeleteOptions.type.ts`): `etag` becomes an `If-Match` header. */ +private[internal] final class StateDeleteOptions( + val etag: js.UndefOr[String] = js.undefined, + val consistency: js.UndefOr[Int] = js.undefined, + val concurrency: js.UndefOr[Int] = js.undefined, + val metadata: js.UndefOr[js.Dictionary[String]] = js.undefined, +) extends js.Object + +/** `OperationType` (`types/Operation.type.ts`): one entry of a `state.transaction` call. */ +private[internal] final class StateTransactionOperation( + val operation: String, + val request: StateTransactionRequest, +) extends js.Object + +/** `IRequest` (`types/Request.type.ts`): the key/value/etag payload of a transaction operation. */ +private[internal] final class StateTransactionRequest( + val key: String, + val value: js.UndefOr[js.Any] = js.undefined, + val etag: js.UndefOr[String] = js.undefined, +) extends js.Object + +/** One item of the raw sidecar response to `POST /v1.0/state/{store}/bulk` (and of `query().results`): the SDK passes + * the parsed JSON `[{key, data, etag}]` through verbatim. `data` is absent for missing keys. + */ +@js.native +private[internal] trait BulkStateItem extends js.Object: + def key: String = js.native + def data: js.UndefOr[js.Any] = js.native + def etag: js.UndefOr[String] = js.native + +/** `StateQueryResponseType`: `{results: [{key, data, etag}]}`; the SDK substitutes `{results: []}` for an empty body + * (`implementation/Client/HTTPClient/state.js` `query`). + */ +@js.native +private[internal] trait StateQueryResponse extends js.Object: + def results: js.UndefOr[js.Array[BulkStateItem]] = js.native + +/** Soft-failure response shape shared by `state.save`, `state.delete` (`StateSaveResponseType`) and `pubsub.publish` + * (`PubSubPublishResponseType`): the SDK catches the rejected `Error` and returns it as `{error}` instead of + * rethrowing (`implementation/Client/HTTPClient/{state,pubsub}.js`). + */ +@js.native +private[internal] trait SoftFailureResponse extends js.Object: + def error: js.UndefOr[js.Error] = js.native + +// --------------------------------------------------------------------------- +// pubsub (interfaces/Client/IClientPubSub.ts) +// --------------------------------------------------------------------------- + +@js.native +private[internal] trait PubSubClient extends js.Object: + def publish( + pubSubName: String, + topic: String, + data: js.Any, + options: PubSubPublishOptions, + ): js.Promise[SoftFailureResponse] = js.native + def publishBulk( + pubSubName: String, + topic: String, + messages: js.Array[PubSubBulkPublishMessage], + ): js.Promise[PubSubBulkPublishResponse] = js.native + +/** `PubSubPublishOptions` (`types/pubsub/PubSubPublishOptions.type.ts`). */ +private[internal] final class PubSubPublishOptions( + val contentType: js.UndefOr[String] = js.undefined, + val metadata: js.UndefOr[js.Dictionary[String]] = js.undefined, +) extends js.Object + +/** Explicit-entry form of `PubSubBulkPublishMessage` (`types/pubsub/PubSubBulkPublishMessage.type.ts`); passing the + * explicit `{entryID, event, contentType, metadata}` shape (detected via `"event" in message`, `utils/Client.util.js` + * `getBulkPublishEntries`) keeps our entry IDs and content type authoritative. + */ +private[internal] final class PubSubBulkPublishMessage( + val entryID: String, + val event: js.Any, + val contentType: js.UndefOr[String] = js.undefined, +) extends js.Object + +/** `PubSubBulkPublishResponse` (`types/pubsub/PubSubBulkPublishResponse.type.ts`). */ +@js.native +private[internal] trait PubSubBulkPublishResponse extends js.Object: + def failedMessages: js.Array[PubSubBulkPublishFailedMessage] = js.native + +@js.native +private[internal] trait PubSubBulkPublishFailedMessage extends js.Object: + def message: PubSubBulkPublishMessage = js.native + def error: js.Error = js.native + +// --------------------------------------------------------------------------- +// binding / invoker / secret (interfaces/Client/IClient{Binding,Invoker,Secret}.ts) +// --------------------------------------------------------------------------- + +@js.native +private[internal] trait BindingClient extends js.Object: + def send(bindingName: String, operation: String, data: js.Any, metadata: js.Dictionary[String]): js.Promise[js.Any] = + js.native + +@js.native +private[internal] trait InvokerClient extends js.Object: + def invoke( + appId: String, + methodName: String, + method: String, + data: js.UndefOr[js.Any], + options: InvokerOptions, + ): js.Promise[js.Any] = js.native + +/** `InvokerOptions` (`types/InvokerOptions.type.ts`): extra HTTP headers for the invocation. */ +private[internal] final class InvokerOptions( + val headers: js.UndefOr[js.Dictionary[String]] = js.undefined, +) extends js.Object + +@js.native +private[internal] trait SecretClient extends js.Object: + /** `metadata` is a pre-rendered query string (e.g. `"metadata.version_id=15"`), appended verbatim after `?` — see + * `implementation/Client/HTTPClient/secret.js`. + */ + def get(secretStoreName: String, key: String, metadata: String): js.Promise[js.Any] = js.native + def get(secretStoreName: String, key: String): js.Promise[js.Any] = js.native + def getBulk(secretStoreName: String): js.Promise[js.Any] = js.native + +// --------------------------------------------------------------------------- +// configuration (gRPC-only: implementation/Client/GRPCClient/configuration.js; +// the HTTP implementation throws HTTPNotSupportedError) +// --------------------------------------------------------------------------- + +@js.native +private[internal] trait ConfigurationClient extends js.Object: + def get( + storeName: String, + keys: js.Array[String], + metadata: js.Dictionary[String], + ): js.Promise[GetConfigurationResponse] = js.native + def subscribeWithMetadata( + storeName: String, + keys: js.Array[String], + metadata: js.Dictionary[String], + cb: js.Function1[SubscribeConfigurationResponse, js.Promise[Unit]], + ): js.Promise[ConfigurationSubscription] = js.native + +/** `GetConfigurationResponse` / `SubscribeConfigurationResponse`: both are `{items: {[key]: ConfigurationItem}}`. */ +@js.native +private[internal] trait GetConfigurationResponse extends js.Object: + def items: js.Dictionary[ConfigurationItemJs] = js.native + +@js.native +private[internal] trait SubscribeConfigurationResponse extends js.Object: + def items: js.Dictionary[ConfigurationItemJs] = js.native + +/** `types/configuration/ConfigurationItem.d.ts`, built by `createConfigurationType` (`utils/Client.util.js`) from the + * protobuf response. `value`/`version` are typed defensively as optional: proto3 string defaults make them `""` in + * practice, mirroring how the JVM impl treats `null` as `""`. + */ +@js.native +private[internal] trait ConfigurationItemJs extends js.Object: + def value: js.UndefOr[String] = js.native + def version: js.UndefOr[String] = js.native + def metadata: js.UndefOr[js.Dictionary[String]] = js.native + +/** `SubscribeConfigurationStream`: handle returned by `subscribe*`; `stop()` is an async arrow function (it aborts the + * stream and sends the explicit unsubscribe call), hence the `js.Promise[Unit]` result. + */ +@js.native +private[internal] trait ConfigurationSubscription extends js.Object: + def stop(): js.Promise[Unit] = js.native + +// --------------------------------------------------------------------------- +// lock (interfaces/Client/IClientLock.ts; v1.0-alpha1 HTTP endpoints) +// --------------------------------------------------------------------------- + +@js.native +private[internal] trait LockClient extends js.Object: + def lock( + storeName: String, + resourceId: String, + lockOwner: String, + expiryInSeconds: Int, + ): js.Promise[LockResponse] = js.native + def unlock(storeName: String, resourceId: String, lockOwner: String): js.Promise[UnlockResponse] = js.native + +@js.native +private[internal] trait LockResponse extends js.Object: + def success: js.UndefOr[Boolean] = js.native + +/** `UnlockResponse`: `status` is the numeric `LockStatus` enum — Success = 0, LockDoesNotExist = 1, LockBelongsToOthers = + * 2, InternalError = 3 (`implementation/Client/HTTPClient/lock.js` `_statusToLockStatus`). + */ +@js.native +private[internal] trait UnlockResponse extends js.Object: + def status: js.UndefOr[Int] = js.native + +// --------------------------------------------------------------------------- +// crypto (gRPC-only: implementation/Client/GRPCClient/crypto.js; the HTTP +// implementation throws HTTPNotSupportedError) +// --------------------------------------------------------------------------- + +@js.native +private[internal] trait CryptoClient extends js.Object: + /** The buffered overload: passing `inData` (any `ArrayBufferView` is accepted by the SDK's `toArrayBuffer`) makes + * `processStream` collect the response stream into a single Node `Buffer` (a `Uint8Array` subclass). The + * zero-`inData` Duplex-stream overload is deliberately not facaded. + */ + def encrypt(inData: js.typedarray.ArrayBufferView, opts: EncryptRequest): js.Promise[js.typedarray.Uint8Array] = + js.native + def decrypt(inData: js.typedarray.ArrayBufferView, opts: DecryptRequest): js.Promise[js.typedarray.Uint8Array] = + js.native + +/** `EncryptRequest` (`types/crypto/Requests.ts`); `keyWrapAlgorithm` is a TS string union, plain `String` at runtime. + */ +private[internal] final class EncryptRequest( + val componentName: String, + val keyName: String, + val keyWrapAlgorithm: String, +) extends js.Object + +/** `DecryptRequest` (`types/crypto/Requests.ts`): the ciphertext embeds the key reference, so only the component is + * required — same contract the JVM impl documents on `CryptoCapabilityImpl.decrypt`. + */ +private[internal] final class DecryptRequest( + val componentName: String, +) extends js.Object + +// --------------------------------------------------------------------------- +// health (interfaces/Client/IClientHealth.ts) — used by the serve() phase +// --------------------------------------------------------------------------- + +@js.native +private[internal] trait HealthClient extends js.Object: + def isHealthy(): js.Promise[Boolean] = js.native diff --git a/src/internal/js/facade/NodeFetch.scala b/src/internal/js/facade/NodeFetch.scala new file mode 100644 index 0000000..6d90c14 --- /dev/null +++ b/src/internal/js/facade/NodeFetch.scala @@ -0,0 +1,37 @@ +//> using target.platform "scala-js" +package dapr4s.internal.facade + +import scala.scalajs.js +import scala.scalajs.js.annotation.JSGlobalScope + +/** Facade for the WHATWG `fetch` available as a Node global since Node 18 (the same floor the Dapr JS SDK requires). + * + * Used by the parts of the JS internal layer that must talk to the sidecar HTTP API directly because the SDK cannot + * express the operation (see [[dapr4s.internal.ActorCapabilityImpl]] and + * [[dapr4s.internal.StateCapabilityImpl.getWithETag]]) — the JS analogue of the JVM `HttpActorContext` raw + * `HttpURLConnection` precedent. + */ +@js.native +@JSGlobalScope +private[internal] object NodeGlobals extends js.Object: + def fetch(url: String, init: FetchRequestInit): js.Promise[FetchResponse] = js.native + +/** The `RequestInit` subset we need. */ +private[internal] final class FetchRequestInit( + val method: String, + val headers: js.Dictionary[String], + val body: js.UndefOr[String] = js.undefined, +) extends js.Object + +/** The `Response` subset we need. `headers.get(name)` returns `null` for absent headers — declared as `String | Null` + * so explicit-nulls forces callers to handle absence. + */ +@js.native +private[internal] trait FetchResponse extends js.Object: + def status: Int = js.native + def text(): js.Promise[String] = js.native + def headers: FetchHeaders = js.native + +@js.native +private[internal] trait FetchHeaders extends js.Object: + def get(name: String): String | Null = js.native diff --git a/src/internal/js/facade/WorkflowSdk.scala b/src/internal/js/facade/WorkflowSdk.scala new file mode 100644 index 0000000..bde8f1d --- /dev/null +++ b/src/internal/js/facade/WorkflowSdk.scala @@ -0,0 +1,88 @@ +//> using target.platform "scala-js" +package dapr4s.internal.facade + +import scala.scalajs.js +import scala.scalajs.js.annotation.JSImport + +// --------------------------------------------------------------------------- +// Facades for the workflow management client of `@dapr/dapr`. +// +// `DaprWorkflowClient` (workflow/client/DaprWorkflowClient.ts) talks gRPC +// directly to the sidecar via the vendored durabletask `TaskHubGrpcClient` +// (workflow/internal/durabletask/client/client.js) — it is the proper workflow +// client; the `client.workflow` building block on `DaprClient` is HTTP-only, +// deprecated-shaped, and not facaded here. +// --------------------------------------------------------------------------- + +/** Facade for `DaprWorkflowClient` (root export of `@dapr/dapr`). + * + * Inputs/outputs/payloads are `JSON.stringify`-ed by the vendored client (`client.js`: `scheduleNewOrchestration`, + * `raiseOrchestrationEvent`, `terminateOrchestration`), so callers control the wire format by choosing what JS value + * to pass — see [[dapr4s.internal.WorkflowCapabilityImpl]] for the JVM-parity rules. + */ +@js.native +@JSImport("@dapr/dapr", "DaprWorkflowClient") +private[dapr4s] class DaprWorkflowClient(options: WorkflowClientOptions) extends js.Object: + def scheduleNewWorkflow( + workflow: String, + input: js.UndefOr[js.Any], + instanceId: js.UndefOr[String], + ): js.Promise[String] = js.native + def terminateWorkflow(workflowInstanceId: String, output: js.Any | Null): js.Promise[Unit] = js.native + def getWorkflowState( + workflowInstanceId: String, + getInputsAndOutputs: Boolean, + ): js.Promise[js.UndefOr[WorkflowState]] = js.native + def waitForWorkflowCompletion( + workflowInstanceId: String, + fetchPayloads: Boolean, + timeoutInSeconds: Double, + ): js.Promise[js.UndefOr[WorkflowState]] = js.native + def raiseEvent(workflowInstanceId: String, eventName: String, eventPayload: js.Any): js.Promise[Unit] = js.native + def purgeWorkflow(workflowInstanceId: String): js.Promise[Boolean] = js.native + def suspendWorkflow(workflowInstanceId: String): js.Promise[Unit] = js.native + def resumeWorkflow(workflowInstanceId: String): js.Promise[Unit] = js.native + def stop(): js.Promise[Unit] = js.native + +/** Facade for `WorkflowClientOptions` (`types/workflow/WorkflowClientOption.ts`). All fields optional; the endpoint is + * resolved as `${daprHost}:${daprPort}` through `GrpcEndpoint` exactly like `GRPCClient` does + * (`workflow/internal/index.js` `generateEndpoint`). + */ +private[dapr4s] final class WorkflowClientOptions( + val daprHost: js.UndefOr[String] = js.undefined, + val daprPort: js.UndefOr[String] = js.undefined, + val daprApiToken: js.UndefOr[String] = js.undefined, +) extends js.Object + +/** Facade for `WorkflowState` (`workflow/client/WorkflowState.ts`) — a class with getters; modelled structurally + * because we only ever consume instances returned by [[DaprWorkflowClient]]. + * + * `runtimeStatus` is the numeric [[WorkflowRuntimeStatus]] enum. `createdAt`/`lastUpdatedAt` are JS `Date`s built from + * the protobuf timestamps (`workflow/internal/durabletask/orchestration/index.js`). `serializedInput`/`Output` are + * JSON strings, `undefined` when payload fetching was off or the value is absent. + */ +@js.native +private[internal] trait WorkflowState extends js.Object: + def name: String = js.native + def instanceId: String = js.native + def runtimeStatus: Int = js.native + def createdAt: js.Date = js.native + def lastUpdatedAt: js.Date = js.native + def serializedInput: js.UndefOr[String] = js.native + def serializedOutput: js.UndefOr[String] = js.native + +/** Facade for the numeric `WorkflowRuntimeStatus` enum (`workflow/runtime/WorkflowRuntimeStatus.ts`): RUNNING = 0, + * COMPLETED = 1, CONTINUED_AS_NEW = 2, FAILED = 3, TERMINATED = 5, PENDING = 6, SUSPENDED = 7. Note there is no + * CANCELED member (protobuf value 4) — the JS SDK omits it. Values are read off the real enum object rather than + * hardcoded, so a renumbering upstream cannot silently corrupt the mapping. + */ +@js.native +@JSImport("@dapr/dapr", "WorkflowRuntimeStatus") +private[internal] object WorkflowRuntimeStatus extends js.Object: + val RUNNING: Int = js.native + val COMPLETED: Int = js.native + val CONTINUED_AS_NEW: Int = js.native + val FAILED: Int = js.native + val TERMINATED: Int = js.native + val PENDING: Int = js.native + val SUSPENDED: Int = js.native diff --git a/src/js/Dapr.scala b/src/js/Dapr.scala new file mode 100644 index 0000000..d42742b --- /dev/null +++ b/src/js/Dapr.scala @@ -0,0 +1,133 @@ +//> using target.platform "scala-js" +package dapr4s + +import scala.scalajs.js +import scala.util.control.NonFatal +import dapr4s.internal.facade + +/** 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 SDK clients it wraps 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 facade.DaprClient(internal.DaprCapabilityImpl.httpClientOptions(sc)) + val grpcClientRef = new internal.LazyClientRef[facade.DaprClient] + val workflowClientRef = new internal.LazyClientRef[facade.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 the inbound app channel (pub/sub subscriptions, invocation routes, bindings, actors) described by the + * [[DaprApp]] returned from `body`. + * + * '''Not implemented on Scala.js yet.''' A follow-up phase adds the implementation on top of the JS SDK's + * `DaprServer` (express-based HTTP app channel). + */ + // TODO(scala-js serve phase): implement over facade'd DaprServer — pubsub.subscribe/invoker.listen/ + // binding.receive for the SDK-supported routes, raw express routes for the dapr4s app-channel extras + // (/dapr/config, actors, jobs), every callback re-entering js.async before dispatch. + def serve(body: DaprCapability ?=> DaprApp): Nothing = + throw new UnsupportedOperationException("dapr4s serve() on Scala.js is not implemented yet") + + /** JS-only convenience twin of [[serve]], mirroring [[runAsync]]. Like [[serve]], not implemented yet. */ + def serveAsync(body: DaprCapability ?=> DaprApp): js.Promise[Nothing] = + js.async { + serve(body) + } diff --git a/test/TestCodecsJs.scala b/test/TestCodecsJs.scala new file mode 100644 index 0000000..1b99b11 --- /dev/null +++ b/test/TestCodecsJs.scala @@ -0,0 +1,146 @@ +//> 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. +// +// 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/integration/apps/InventoryServiceMain.scala b/test/integration/apps/InventoryServiceMain.scala index 136e94b..749462f 100644 --- a/test/integration/apps/InventoryServiceMain.scala +++ b/test/integration/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/integration/apps/OrderServiceMain.scala index 28be531..f1cd134 100644 --- a/test/integration/apps/OrderServiceMain.scala +++ b/test/integration/apps/OrderServiceMain.scala @@ -1,3 +1,4 @@ +//> using target.platform "jvm" package dapr4s.test.integration.apps import dapr4s.* 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..2b1bb54 --- /dev/null +++ b/wiki/dapr/dapr-js-sdk.md @@ -0,0 +1,139 @@ +# 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 +> Raw: [dapr-js-sdk source survey](../../raw/dapr/2026-06-11-dapr-js-sdk-source-survey.md) +> Updated: 2026-06-11 + +## 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). + +## 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. + +## 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. +- 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 throws `UnsupportedOperationException` from these capabilities on JS (or implements them with raw HTTP). + +## 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) +- [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 8f6cf07..a2cb934 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, DaprServer, actors (class-name reflection hazard), workflows (async-generator model, *WithName variants), serialization/error rules, missing jobs/conversation | 2026-06-11 | | [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,13 @@ 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`, dep-directive platform leak + jvm-deps.scala/--exclude pattern, `::` dep syntax, publish --cross (_3 + _sjs1_3), scala-cli >= 1.13.0 floor, cwd-based npm resolution, GH Actions | 2026-06-11 | +| [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 | 2026-06-11 | +| [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 a3532fb..1577e08 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1,5 +1,13 @@ # Wiki Log +## [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..795a3a1 --- /dev/null +++ b/wiki/scala-js/scala-js-async-jspi-wasm.md @@ -0,0 +1,78 @@ +# 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 +> Raw: [sync-looking APIs on Scala.js research report](../../raw/scala-js/2026-06-11-scalajs-async-jspi.md) +> Updated: 2026-06-11 + +## 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. + +## 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..307ef54 --- /dev/null +++ b/wiki/scala-js/scala-js-cross-building-scala-cli.md @@ -0,0 +1,84 @@ +# 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 +> Raw: [scala-cli cross-platform probe report](../../raw/scala-js/2026-06-11-scala-cli-crossplatform.md) +> Updated: 2026-06-11 + +## 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 **dependency-directive platform leak** (no platform-scoped deps) 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). + +## CRITICAL: dependency directives leak across platforms + +`using dep` / `using test.dep` directives written inside a `target.platform`-tagged file are **NOT scoped to that platform** — they apply to all platforms. Verified failure: `//> using test.dep com.dimafeng::testcontainers-scala-munit::0.43.6` inside a jvm-tagged test file made `scala-cli test --js .` fail resolving `testcontainers-scala-munit_sjs1_3` (404). There is **no platform-conditional dependency directive**. + +**The dapr4s pattern (`jvm-deps.scala` + `--exclude`):** put all JVM-only dep directives (`io.dapr:*`, testcontainers) into a dedicated `jvm-deps.scala` file at the repo root. Default invocations (`scala-cli compile/test .`) include it, so JVM workflows are unchanged; JS invocations pass `--exclude jvm-deps.scala`, keeping both resolution and the published `_sjs1_3` POM clean. (Alternative: pass JVM-only deps as CLI `--dep` flags on JVM invocations only.) Note that plain Java deps (single `:`) resolve fine on JS — but they'd still pollute the `_sjs1_3` POM, so they need the same treatment. Cost either way: single-shot `test --cross` can't be used when any platform needs excluded/CLI-only deps; run `test .` and `test --js . --exclude jvm-deps.scala` as two steps. + +## 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, to combine with `--exclude jvm-deps.scala`). +- 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 +- [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) From f69025b033d405f8a65c30ff205ac703ac40861e Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Thu, 11 Jun 2026 03:02:15 +0200 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20Scala.js=20serve()=20=E2=80=94=20?= =?UTF-8?q?express-based=20DaprAppServer=20twin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the JVM design exactly: the JVM hand-rolls the app-channel protocol on com.sun.net.httpserver (the Java SDK server is not used either), so the JS twin hand-rolls the same protocol on express 4 (an @dapr/dapr dependency): - facade/Express.scala: typed express facade (JSImport.Default proven correct for CJS+ESM interop by runtime smoke tests) - HttpActorContext.scala: fetch-based twin (same routes, bodies, semantics; also sends dapr-api-token, which the JVM twin omits) - DaprAppServer.scala: /dapr/subscribe, /dapr/config, CloudEvent subscription dispatch, all-verb invoke routes, binding/job routes, actor method/timer/ reminder/deactivate routes; every handler re-enters js.async per request; SIGINT/SIGTERM drain with shutdownGrace - WorkflowHost.scala: seam for the workflow-hosting phase (throws for now) - src/js/Dapr.scala: serve() wired up, scaladoc updated Verified end-to-end on Wasm+JSPI with Node 25.5 against curl: subscribe/config JSON shape, SUCCESS/RETRY/DROP mapping, invoke/binding/job/actor dispatch, base64 timer payload unwrap, SIGTERM clean exit. Co-Authored-By: Claude Fable 5 --- .scalafmt.conf | 1 + src/internal/js/DaprAppServer.scala | 651 +++++++++++++++++++++++++ src/internal/js/HttpActorContext.scala | 145 ++++++ src/internal/js/WorkflowHost.scala | 51 ++ src/internal/js/facade/Express.scala | 169 +++++++ src/js/Dapr.scala | 73 ++- 6 files changed, 1081 insertions(+), 9 deletions(-) create mode 100644 src/internal/js/DaprAppServer.scala create mode 100644 src/internal/js/HttpActorContext.scala create mode 100644 src/internal/js/WorkflowHost.scala create mode 100644 src/internal/js/facade/Express.scala diff --git a/.scalafmt.conf b/.scalafmt.conf index 1b5c9ea..08b2878 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -21,6 +21,7 @@ project.excludeFilters = [ "src/internal/DaprCapabilityImpl\\.scala", "src/internal/WorkflowContextImpl\\.scala", "src/internal/js/ConfigurationCapabilityImpl\\.scala", + "src/internal/js/DaprAppServer\\.scala", "src/internal/js/DaprCapabilityImpl\\.scala", "test/integration/apps/WorkflowApp\\.scala", "test/unit/CapabilityDerivationFixtures\\.scala", diff --git a/src/internal/js/DaprAppServer.scala b/src/internal/js/DaprAppServer.scala new file mode 100644 index 0000000..8bda908 --- /dev/null +++ b/src/internal/js/DaprAppServer.scala @@ -0,0 +1,651 @@ +//> 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 + +/** 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). 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.Express() + expressApp.use( + facade.Express.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.all( + "/dapr/subscribe", + (req, res) => + handleAsync(res, "/dapr/subscribe") { () => + if req.method == "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.all( + "/dapr/config", + (req, res) => + handleAsync(res, "/dapr/config") { () => + if req.method == "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.put( + "/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.put( + "/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.put( + "/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.delete( + "/actors/:actorType/:actorId", + (req, res) => + handleAsync(res, req.path) { () => + // 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.all( + 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.all( + 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.all( + path, + erased((req, res) => handleAsync(res, path)(() => sendJson(res, 200, fn(req.method, 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.all( + 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.use((req, res, next) => sendEmpty(res, 404)) + + // ----------------------------------------------------------------------- + // Lifecycle + // ----------------------------------------------------------------------- + + val server = + if httpBacklog > 0 then expressApp.listen(port, httpBacklog, () => ()) + else expressApp.listen(port, () => ()) + + // 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 = + server.close(() => facade.NodeProcess.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)(facade.NodeProcess.exit(0)): Unit + facade.NodeProcess.on("SIGINT", () => shutdown()): Unit + facade.NodeProcess.on("SIGTERM", () => 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", + (err: js.Error) => { + reject(err) + () + }, + ): Unit, + ) + JsAwait.await(serverFailure) + +@scala.caps.assumeSafe +private object DaprAppServer: + + // ------------------------------------------------------------------------- + // Per-request async entry + // ------------------------------------------------------------------------- + + /** WHAT: `asInstanceOf` erasing an express handler lambda's inferred capture set (`ExpressHandler^` accepts any + * capturing handler; the cast forgets the set). + * + * WHY: `js.Function2` is a Scala-defined SAM, so CC tracks the closure's captures — the route handlers for + * user-defined routes capture their dispatch closure (`fn`), which transitively reaches the enclosing + * `DaprAppServer`. The facade signature (`ExpressApp.all` etc.) mirrors 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; shutdown exits the process). Same + * erasure rationale as `ConfigurationCapabilityImpl.subscribe`'s callback cast and the `AnyRef`-erasure pattern + * documented in AGENTS.md. + */ + private def erased(handler: facade.ExpressHandler^): facade.ExpressHandler = + handler.asInstanceOf[facade.ExpressHandler] + + /** 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: facade.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: facade.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: facade.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: facade.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: facade.ExpressRequest, name: String): String = + req.params.getOrElse(name, "") + + /** 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. + */ + private def readBody(req: facade.ExpressRequest): String = + (req.body: Any) match + case s: String => s + case _ => "" + + private def sendJson(res: facade.ExpressResponse, code: Int, body: String): Unit = + res.status(code).`type`("application/json").send(body) + + /** Status code with an empty body — the JVM's `exchange.sendResponseHeaders(code, -1)`. */ + private def sendEmpty(res: facade.ExpressResponse, code: Int): Unit = + res.status(code).end() + + 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/internal/js/HttpActorContext.scala b/src/internal/js/HttpActorContext.scala new file mode 100644 index 0000000..419f9c0 --- /dev/null +++ b/src/internal/js/HttpActorContext.scala @@ -0,0 +1,145 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.concurrent.duration.FiniteDuration +import scala.scalajs.js + +/** [[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` + [[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) + + private def stateUrl(key: ActorStateKey): String = + s"$base/v1.0/actors/${actorType.value}/${actorId.value}/state/${key.value}" + + private def bulkStateUrl: String = + s"$base/v1.0/actors/${actorType.value}/${actorId.value}/state" + + private def reminderUrl(name: ReminderName): String = + s"$base/v1.0/actors/${actorType.value}/${actorId.value}/reminders/${name.value}" + + private def timerUrl(name: TimerName): String = + s"$base/v1.0/actors/${actorType.value}/${actorId.value}/timers/${name.value}" + + // ---- State ----------------------------------------------------------------- + + def get[T: JsonCodec](key: ActorStateKey): Option[T] = + val url = stateUrl(key) + val init = new facade.FetchRequestInit(method = "GET", headers = ActorCapabilityImpl.baseHeaders(sidecar)) + val response = JsAwait.await(facade.NodeGlobals.fetch(url, init)) + val code = response.status + if code == 204 || code == 404 then None + else + val text = JsAwait.await(response.text()) + // 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. + 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 = new facade.FetchRequestInit( + method = "POST", + headers = ActorCapabilityImpl.baseHeaders(sidecar), + body = body, + ) + val response = JsAwait.await(facade.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 = new facade.FetchRequestInit(method = "DELETE", headers = ActorCapabilityImpl.baseHeaders(sidecar)) + val response = JsAwait.await(facade.NodeGlobals.fetch(url, init)) + val _ = JsAwait.await(response.text()) diff --git a/src/internal/js/WorkflowHost.scala b/src/internal/js/WorkflowHost.scala new file mode 100644 index 0000000..b8f32a9 --- /dev/null +++ b/src/internal/js/WorkflowHost.scala @@ -0,0 +1,51 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* + +/** Seam for server-side workflow/activity hosting on Scala.js — the JS counterpart of the JVM `DaprAppServer`'s + * `WorkflowRuntimeBuilder` block. + * + * [[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). + */ +// TODO(scala-js workflow-hosting phase): replace the throwing body with a real implementation over the +// facade'd @dapr/dapr WorkflowRuntime — registerWorkflowWithName/registerActivityWithName, the +// js.async-based coroutine bridge for Workflow.run, and a Handle that stops the runtime. The signature +// below is the stable seam: DaprAppServer already wires workflows, activities, the live DaprCapability +// (activities receive it, like the JVM's WorkflowActivityBridge), and the SidecarConfig (gRPC endpoint + +// api token for the runtime's durabletask connection, cf. DaprCapabilityImpl.workflowClientOptions). +@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`. + * + * @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 = + throw new UnsupportedOperationException("dapr4s workflow hosting on Scala.js lands in the next phase") diff --git a/src/internal/js/facade/Express.scala b/src/internal/js/facade/Express.scala new file mode 100644 index 0000000..0b50b8a --- /dev/null +++ b/src/internal/js/facade/Express.scala @@ -0,0 +1,169 @@ +//> using target.platform "scala-js" +package dapr4s.internal.facade + +import scala.scalajs.js +import scala.scalajs.js.annotation.{JSGlobal, JSImport} + +// --------------------------------------------------------------------------- +// Scala.js facades for express 4 (a dependency of `@dapr/dapr`, so always +// present in node_modules) plus the two Node builtins the server lifecycle +// needs (`http.Server`, `process`). Used exclusively by +// `dapr4s.internal.DaprAppServer` — the JS twin of the JVM app-channel server, +// which hand-rolls the Dapr app-channel HTTP protocol instead of using the +// SDK's `DaprServer` (whose callbacks strip the CloudEvent envelope and +// constrain HTTP verbs). +// +// Signatures verified against the installed sources in node_modules/express +// (v4.22.2): lib/express.js, lib/application.js, lib/request.js, +// lib/response.js, lib/router/. Runtime-verified by a packaged Scala.js smoke +// app under BOTH CommonJS and ES module kinds. +// --------------------------------------------------------------------------- + +/** An express route handler. Express invokes handlers with `(req, res, next)`; we never call `next` from a terminal + * route handler, and a JS function created from a `js.Function2` lambda simply ignores the extra argument (standard + * JavaScript arity semantics). + */ +private[internal] type ExpressHandler = js.Function2[ExpressRequest, ExpressResponse, Unit] + +/** An express middleware function `(req, res, next)`. Express distinguishes error-handling middleware by + * `fn.length == 4`, so a 3-parameter function is always treated as ordinary middleware. + */ +private[internal] type ExpressMiddleware = js.Function3[ExpressRequest, ExpressResponse, js.Function0[Unit], Unit] + +/** Facade for the express module itself (`lib/express.js`). + * + * ==Why `JSImport.Default` (and not `Namespace`)== + * + * express is a classic CommonJS module: `module.exports = createApplication` — a '''callable function''' that also + * carries the middleware factories (`text`, `json`, ...) as properties. The two Scala.js module kinds bind a default + * import differently: + * + * - under `jsModuleKind commonjs`, Scala.js resolves `JSImport.Default` through its `$moduleDefault` interop helper + * (`m.__esModule ? m.default : m`); express sets no `__esModule` flag, so we get `module.exports` itself; + * - under `jsModuleKind es` (the Wasm/JSPI production target), `import { default as e } from "express"` binds Node's + * CJS↔ESM interop default, which is again `module.exports`. + * + * `JSImport.Namespace` would break under ES modules: an `import * as ns` namespace object is '''never callable''', so + * `express()` would throw `TypeError: ns is not a function`. `Default` is the only binding that yields the callable + * function under both module kinds (verified at runtime under both). + * + * 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 Express extends js.Object: + + /** `express()` — create an application (`lib/express.js` `createApplication`). */ + def apply(): ExpressApp = 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 `ExpressRequest.body`. + */ + def text(options: ExpressTextOptions): ExpressMiddleware = js.native + +/** Options for [[Express.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 + +/** The express application (`lib/application.js`). The HTTP-verb methods are generated by the `methods.forEach` block + * (line 489); each registers a route handler for that verb only. We always pass a path AND a handler, so the + * single-argument `app.get(setting)` settings-getter overload is never hit. + */ +@js.native +private[internal] trait ExpressApp extends js.Object: + + /** Mount application-wide middleware (`app.use(fn)`); runs in registration order before/after routes. */ + def use(middleware: ExpressMiddleware): Unit = js.native + + def get(path: String, handler: ExpressHandler): Unit = js.native + def post(path: String, handler: ExpressHandler): Unit = js.native + def put(path: String, handler: ExpressHandler): Unit = js.native + def delete(path: String, handler: ExpressHandler): Unit = js.native + + /** Register `handler` for the path under '''every''' HTTP method (`app.all`, line 514). */ + def all(path: String, handler: ExpressHandler): Unit = js.native + + /** `app.listen` delegates verbatim to Node's `net.Server.listen` (`http.createServer(this)` + `server.listen.apply`, + * line 633). The `(port, backlog, callback)` arity is supported by Node's argument normalisation: a numeric second + * argument is taken as the backlog (`toNumber(args[1])` in `net.js`). Returns the underlying `http.Server`. + */ + def listen(port: Int, callback: js.Function0[Unit]): NodeHttpServer = js.native + def listen(port: Int, backlog: Int, callback: js.Function0[Unit]): NodeHttpServer = js.native + +/** The express request (`lib/request.js`, augmented by the router). Only the members the dispatch layer reads. */ +@js.native +private[internal] trait ExpressRequest extends js.Object: + + /** Named route parameters (`:name` segments), URI-decoded by the router (`lib/router/layer.js` `decode_param`). */ + def params: js.Dictionary[String] = js.native + + /** The parsed body. With the [[Express.text]] middleware mounted this is the raw body '''string''' — except for + * requests body-parser skips (no body / no `Content-Type`), where it is the `{}` placeholder object body-parser + * assigns unconditionally (`req.body = req.body || {}`). Callers must therefore type-test for `String`. + */ + def body: js.Any = js.native + + /** The HTTP verb, upper-case (Node `IncomingMessage.method`). */ + def method: String = js.native + + /** The URL pathname, query string excluded (getter at `lib/request.js` line 412). */ + def path: String = js.native + + /** `req.get(name)`/`req.header(name)`: the request header, or `undefined` when absent. */ + def get(name: String): js.UndefOr[String] = js.native + +/** The express response (`lib/response.js`). Only the members the dispatch layer writes. */ +@js.native +private[internal] trait ExpressResponse extends js.Object: + + /** Set the response status code (line 67); returns `this` for chaining. */ + def status(code: Int): this.type = js.native + + /** Send a string body and finish the response (line 111). Sets `Content-Length`; the content type defaults to + * `text/html` for strings unless set beforehand via [[`type`]]. + */ + def send(body: String): Unit = js.native + + /** `res.type(t)` (line 619): set `Content-Type`; values containing `/` are used verbatim. */ + def `type`(contentType: String): this.type = js.native + + /** Finish the response without a body (inherited Node `ServerResponse.end`) — the express twin of the JVM's + * `exchange.sendResponseHeaders(code, -1)`. + */ + def end(): Unit = js.native + +/** The Node `http.Server` returned by [[ExpressApp.listen]] — only the lifecycle members the shutdown path needs. */ +@js.native +private[internal] trait NodeHttpServer extends js.Object: + + /** Stop accepting new connections; `callback` fires once all in-flight connections have closed. */ + def close(callback: js.Function0[Unit]): Unit = js.native + + /** Subscribe to a server event; `"error"` delivers bind failures (e.g. `EADDRINUSE`) as an `Error`. */ + def on(event: String, listener: js.Function1[js.Error, Unit]): js.Any = js.native + +/** The Node `process` global — signal handling and explicit exit for the shutdown path. */ +@js.native +@JSGlobal("process") +private[internal] object NodeProcess extends js.Object: + + /** Register a signal listener (`"SIGINT"`, `"SIGTERM"`). Once registered, Node's default terminate-on-signal + * behaviour is replaced — the listener '''must''' eventually call [[exit]] itself. + */ + def on(event: String, listener: js.Function0[Unit]): js.Any = js.native + + /** Terminate the process with the given exit code. */ + def exit(code: Int): Nothing = js.native diff --git a/src/js/Dapr.scala b/src/js/Dapr.scala index d42742b..fb849d0 100644 --- a/src/js/Dapr.scala +++ b/src/js/Dapr.scala @@ -114,19 +114,74 @@ class Dapr(config: DaprConfig = DaprConfig()): run(body) } - /** Start the inbound app channel (pub/sub subscriptions, invocation routes, bindings, actors) described by the - * [[DaprApp]] returned from `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`. * - * '''Not implemented on Scala.js yet.''' A follow-up phase adds the implementation on top of the JS SDK's - * `DaprServer` (express-based HTTP app channel). + * 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 on Scala.js is not implemented yet: a [[DaprApp]] with non-empty `workflows` or + * `activities` currently throws `UnsupportedOperationException` at startup (see `dapr4s.internal.WorkflowHost`). + * + * @param body + * a pure context function that receives a `DaprCapability` and returns a [[DaprApp]] describing all inbound + * handlers */ - // TODO(scala-js serve phase): implement over facade'd DaprServer — pubsub.subscribe/invoker.listen/ - // binding.receive for the SDK-supported routes, raw express routes for the dapr4s app-channel extras - // (/dapr/config, actors, jobs), every callback re-entering js.async before dispatch. def serve(body: DaprCapability ?=> DaprApp): Nothing = - throw new UnsupportedOperationException("dapr4s serve() on Scala.js is not implemented yet") + 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]]. Like [[serve]], not implemented yet. */ + /** 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) From 582c07b643927a398cc609aec5fc19bcc37af7b1 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Thu, 11 Jun 2026 03:41:48 +0200 Subject: [PATCH 04/17] =?UTF-8?q?feat:=20Scala.js=20workflow=20hosting=20?= =?UTF-8?q?=E2=80=94=20AsyncGenerator=20coroutine=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dapr JS SDK drives workflows as async generators yielding Task objects; dapr4s workflows are direct-style with blocking Task.await(). The bridge: - WorkflowCoroutine.scala: hand-written AsyncGenerator (next/throw + Symbol.asyncIterator) over a js.async fiber; Task.await() yields the SDK Task and suspends via JSPI until next(v)/throw(e) resumes it. Safety: the executor awaits every step, so fiber and generator strictly alternate on the single JS thread - WorkflowContextImpl.scala (JS twin): callActivity/createTimer/ waitForExternalEvent(+timeout)/complete/continueAsNew/getInput; newUuid mirrors the Java SDK's deterministic v5/SHA-1 algorithm (same namespace, instanceId-timestamp-counter) via node:crypto - WorkflowHost.scala: registers workflows (getClass.getSimpleName) and activities (activityName) with WorkflowRuntime; activity callbacks re-enter js.async; close() is synchronous fire-and-forget (signal-handler frame) - DaprCapabilityImpl: fix gRPC endpoint mapping — grpc:// schemes render as 'grpc:host:port' which grpc-js cannot resolve; plaintext now passes bare host, TLS passes https://host (the only spelling that sets tls) Proven by a 30/30 fake-executor harness (Node 25 Wasm+JSPI) and a real daprd 1.17 e2e: typed callActivity + callActivityByName workflow completed across multiple replay episodes. Co-Authored-By: Claude Fable 5 --- .scalafmt.conf | 1 + src/internal/js/DaprCapabilityImpl.scala | 30 +- src/internal/js/WorkflowContextImpl.scala | 229 +++++++++++++++ src/internal/js/WorkflowCoroutine.scala | 334 ++++++++++++++++++++++ src/internal/js/WorkflowHost.scala | 88 +++++- src/internal/js/facade/NodeCrypto.scala | 25 ++ src/internal/js/facade/WorkflowSdk.scala | 89 ++++++ 7 files changed, 778 insertions(+), 18 deletions(-) create mode 100644 src/internal/js/WorkflowContextImpl.scala create mode 100644 src/internal/js/WorkflowCoroutine.scala create mode 100644 src/internal/js/facade/NodeCrypto.scala diff --git a/.scalafmt.conf b/.scalafmt.conf index 08b2878..a7e33ba 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -23,6 +23,7 @@ project.excludeFilters = [ "src/internal/js/ConfigurationCapabilityImpl\\.scala", "src/internal/js/DaprAppServer\\.scala", "src/internal/js/DaprCapabilityImpl\\.scala", + "src/internal/js/WorkflowContextImpl\\.scala", "test/integration/apps/WorkflowApp\\.scala", "test/unit/CapabilityDerivationFixtures\\.scala", "test/unit/WorkflowActivityDerivationFixtures\\.scala", diff --git a/src/internal/js/DaprCapabilityImpl.scala b/src/internal/js/DaprCapabilityImpl.scala index a088cd0..4295d96 100644 --- a/src/internal/js/DaprCapabilityImpl.scala +++ b/src/internal/js/DaprCapabilityImpl.scala @@ -112,11 +112,22 @@ 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 the URI scheme is kept - * (`http://host` + port) to preserve TLS/plaintext selection. For gRPC clients the scheme is translated to the - * SDK's preferred `grpc`/`grpcs` (passing `http`/`https` works but triggers a deprecation `console.warn` in - * `GrpcEndpoint.setScheme`). 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`). + * `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 @@ -125,9 +136,9 @@ private[dapr4s] object DaprCapabilityImpl: val scheme = if forGrpc then rawScheme match - case "http" => "grpc" - case "https" => "grpcs" - case other => other + 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" @@ -137,7 +148,8 @@ private[dapr4s] object DaprCapabilityImpl: case -1 if rawScheme == "https" => 443 case -1 => 80 case p => p - (s"$scheme://$host", port.toString) + val hostPart = if scheme.isEmpty then host else s"$scheme://$host" + (hostPart, port.toString) private def undefOr[A](o: Option[A]): js.UndefOr[A] = o.fold[js.UndefOr[A]](js.undefined)(a => a) diff --git a/src/internal/js/WorkflowContextImpl.scala b/src/internal/js/WorkflowContextImpl.scala new file mode 100644 index 0000000..58d31ac --- /dev/null +++ b/src/internal/js/WorkflowContextImpl.scala @@ -0,0 +1,229 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.concurrent.duration.FiniteDuration +import scala.scalajs.js +import unsafeExceptions.canThrowAny + +/** 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. + * + * [[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 (see the + * [[facade.SdkTask]] doc), whereas the JVM SDK cancels e.g. timed-out external-event tasks. + */ +@scala.caps.assumeSafe +private[internal] final class TaskImpl[+O]( + private val sdkTask: facade.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. + * + * ==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: facade.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: facade.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(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`, 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 = facade.NodeCrypto.createHash("sha1").update(s"$UuidNamespace-$name", "utf8").digest("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/internal/js/WorkflowCoroutine.scala b/src/internal/js/WorkflowCoroutine.scala new file mode 100644 index 0000000..fb1abc2 --- /dev/null +++ b/src/internal/js/WorkflowCoroutine.scala @@ -0,0 +1,334 @@ +//> using target.platform "scala-js" +package dapr4s.internal + +import dapr4s.* +import scala.scalajs.js +import scala.scalajs.js.annotation.JSName + +/** 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: facade.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: facade.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/internal/js/WorkflowHost.scala b/src/internal/js/WorkflowHost.scala index b8f32a9..631695f 100644 --- a/src/internal/js/WorkflowHost.scala +++ b/src/internal/js/WorkflowHost.scala @@ -2,21 +2,16 @@ package dapr4s.internal import dapr4s.* +import scala.scalajs.js -/** Seam for server-side workflow/activity hosting on Scala.js — the JS counterpart of the JVM `DaprAppServer`'s - * `WorkflowRuntimeBuilder` block. +/** Server-side workflow/activity hosting on Scala.js — the JS counterpart of the JVM `DaprAppServer`'s + * `WorkflowRuntimeBuilder` block, backed by the SDK's [[facade.WorkflowRuntime]] and the [[WorkflowCoroutine]] bridge. * * [[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). */ -// TODO(scala-js workflow-hosting phase): replace the throwing body with a real implementation over the -// facade'd @dapr/dapr WorkflowRuntime — registerWorkflowWithName/registerActivityWithName, the -// js.async-based coroutine bridge for Workflow.run, and a Handle that stops the runtime. The signature -// below is the stable seam: DaprAppServer already wires workflows, activities, the live DaprCapability -// (activities receive it, like the JVM's WorkflowActivityBridge), and the SidecarConfig (gRPC endpoint + -// api token for the runtime's durabletask connection, cf. DaprCapabilityImpl.workflowClientOptions). @scala.caps.assumeSafe private[internal] object WorkflowHost: @@ -29,6 +24,17 @@ private[internal] object WorkflowHost: 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 [[facade.WorkflowRuntime]] 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) @@ -48,4 +54,68 @@ private[internal] object WorkflowHost: daprCapability: DaprCapability, sidecar: SidecarConfig, ): Handle = - throw new UnsupportedOperationException("dapr4s workflow hosting on Scala.js lands in the next phase") + val runtime = new facade.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 facade's js.Function2. + val fn: js.Function2[facade.SdkWorkflowContext, js.Any, js.Any] = + (sdkCtx, input) => new WorkflowCoroutine(w, sdkCtx, 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 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), 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: js.Function2[facade.SdkWorkflowActivityContext, js.Any, js.Any] = + (_, input) => js.async(runActivity(a, daprRef, 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 facade 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/internal/js/facade/NodeCrypto.scala b/src/internal/js/facade/NodeCrypto.scala new file mode 100644 index 0000000..9bcd130 --- /dev/null +++ b/src/internal/js/facade/NodeCrypto.scala @@ -0,0 +1,25 @@ +//> using target.platform "scala-js" +package dapr4s.internal.facade + +import scala.scalajs.js +import scala.scalajs.js.annotation.JSImport + +/** Facade for the Node.js built-in `node:crypto` module — only the one-shot hashing subset needed by the deterministic + * `WorkflowContext.newUuid` implementation (`dapr4s.internal.WorkflowContextImpl` on Scala.js). + * + * The JS internal layer already requires Node (the Dapr JS SDK itself is Node-only), so depending on a Node built-in + * here adds no new platform constraint. `java.security.MessageDigest` is not part of the Scala.js javalib, which is + * why SHA-1 comes from the host platform instead. + */ +@js.native +@JSImport("node:crypto", JSImport.Namespace) +private[internal] object NodeCrypto extends js.Object: + def createHash(algorithm: String): NodeHash = js.native + +/** The `Hash` object returned by `crypto.createHash` — used in the chained one-shot form + * `createHash("sha1").update(data, "utf8").digest("hex")`. + */ +@js.native +private[internal] trait NodeHash extends js.Object: + def update(data: String, inputEncoding: String): NodeHash = js.native + def digest(encoding: String): String = js.native diff --git a/src/internal/js/facade/WorkflowSdk.scala b/src/internal/js/facade/WorkflowSdk.scala index bde8f1d..4b662b4 100644 --- a/src/internal/js/facade/WorkflowSdk.scala +++ b/src/internal/js/facade/WorkflowSdk.scala @@ -71,6 +71,95 @@ private[internal] trait WorkflowState extends js.Object: def serializedInput: js.UndefOr[String] = js.native def serializedOutput: js.UndefOr[String] = js.native +/** Facade for `WorkflowRuntime` (root export of `@dapr/dapr`, `workflow/runtime/WorkflowRuntime.ts`) — the server-side + * workflow/activity host. It shares [[WorkflowClientOptions]] with [[DaprWorkflowClient]] (same `generateEndpoint` + + * API-token interceptor wiring) and drives the vendored durabletask `TaskHubGrpcWorker`. + * + * Lifecycle facts verified in the vendored 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 `workflow` callback receives the '''public''' [[SdkWorkflowContext]] 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 + * [[dapr4s.internal.WorkflowCoroutine]] hands back. + * + * The activity callback receives [[SdkWorkflowActivityContext]] 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. + */ +@js.native +@JSImport("@dapr/dapr", "WorkflowRuntime") +private[internal] class WorkflowRuntime(options: WorkflowClientOptions) extends js.Object: + def registerWorkflowWithName( + name: String, + workflow: js.Function2[SdkWorkflowContext, js.Any, js.Any], + ): WorkflowRuntime = js.native + def registerActivityWithName( + name: String, + fn: js.Function2[SdkWorkflowActivityContext, js.Any, js.Any], + ): WorkflowRuntime = js.native + def start(): js.Promise[Unit] = js.native + def stop(): js.Promise[Unit] = js.native + +/** Facade for the public `WorkflowContext` wrapper class (`workflow/runtime/WorkflowContext.ts`) handed to registered + * workflow functions. Structural (no `@JSImport`): instances are only ever received from [[WorkflowRuntime]]. + * + * Named `Sdk*` (unlike the other facades, which reuse the SDK names) because the natural names collide with the dapr4s + * public types `WorkflowContext`/`Task` that the very same implementation files must also reference. + * + * Only the members dapr4s calls are declared. Verified against `WorkflowContext.js` + the inner + * `worker/runtime-orchestration-context.js`: + * - `createTimer` accepts a JS `Date` or a '''number of seconds''' (`fireAt * 1000` is added to the deterministic + * `currentUtcDateTime` when a non-`Date` is passed) — declared here with the seconds overload only. + * - `callActivity` accepts the activity name or function; dapr4s always passes the registered name (string). + * - `whenAny` returns a `WhenAnyTask` whose result is the first-completed '''child `Task` object''' (not its value) + * — see `task/when-any-task.js` `onChildCompleted`. + * - `continueAsNew(newInput, saveEvents)` only records state (`setContinuedAsNew`); unlike the Java SDK it does not + * throw — the dapr4s impl adds the stack-unwinding signal itself (see `WorkflowContextImpl.continueAsNew`). + */ +@js.native +private[internal] trait SdkWorkflowContext extends js.Object: + def getWorkflowInstanceId(): String = js.native + def getCurrentUtcDateTime(): js.Date = js.native + def isReplaying(): Boolean = js.native + def createTimer(fireAtSeconds: Double): SdkTask = js.native + def callActivity(activity: String, input: js.Any): SdkTask = js.native + def waitForExternalEvent(name: String): SdkTask = js.native + def continueAsNew(newInput: js.Any, saveEvents: Boolean): Unit = js.native + def whenAny(tasks: js.Array[SdkTask]): SdkTask = js.native + +/** Facade for the vendored durabletask `Task` base class (`workflow/internal/durabletask/task/task.js`) — the values + * the orchestration executor accepts as generator yields (`runtime-orchestration-context.js` checks `value instanceof + * Task`, so dapr4s must yield these very instances, never wrappers). Structural: instances are only ever produced by + * [[SdkWorkflowContext]] methods. + * + * `isComplete`/`isFailed` are JS getter properties (declared parameterless). `getResult()` returns the completed value + * and '''throws''' the stored `TaskFailedError` when the task failed. There is no cancellation concept in the JS SDK's + * task model (the Java SDK's `isCancelled` has no counterpart). + */ +@js.native +private[internal] trait SdkTask extends js.Object: + def isComplete: Boolean = js.native + def isFailed: Boolean = js.native + def getResult(): js.Any = js.native + +/** Facade for the public `WorkflowActivityContext` wrapper (`workflow/runtime/WorkflowActivityContext.ts`) handed to + * registered activity functions. Structural; dapr4s does not currently read it (activity input arrives as the second + * callback argument), but the members are declared for completeness of the seam. + */ +@js.native +private[internal] trait SdkWorkflowActivityContext extends js.Object: + def getWorkflowInstanceId(): String = js.native + def getWorkflowActivityId(): Double = js.native + /** Facade for the numeric `WorkflowRuntimeStatus` enum (`workflow/runtime/WorkflowRuntimeStatus.ts`): RUNNING = 0, * COMPLETED = 1, CONTINUED_AS_NEW = 2, FAILED = 3, TERMINATED = 5, PENDING = 6, SUSPENDED = 7. Note there is no * CANCELED member (protobuf value 4) — the JS SDK omits it. Values are read off the real enum object rather than From 256b7075bd06d67ae07bc782a2f801e5a0eaaa22 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Thu, 11 Jun 2026 03:59:10 +0200 Subject: [PATCH 05/17] docs+ci: cross-platform documentation, test-js job, dual publish - ci.yml: new test-js job (compile + unit tests on the plain JS backend); publish now needs it and publishes both dapr4s_3 and dapr4s_sjs1_3 - README: platforms section with the JS consumer recipe (Wasm+JSPI, Node 25+, @dapr/dapr), corrected requirements, --js build/test commands - DESIGN.md: JS internal-layer diagram, per-platform async model, new 'Scala.js platform' section (capability matrix, coroutine bridge, build pattern, divergence table), project structure refreshed to on-disk reality - AGENTS.md: JS wall rules (facades in dapr4s.internal.facade, JsAwait-only orphan await, per-JS-frame js.async re-entry), JS commands, stale WorkflowCapability non-goal replaced - SPEC.allium: platform note on the external client entity - DaprConfig scaladoc: accurate JS-honoured knob list - wiki: implementation field notes (express CJS interop, GrpcEndpoint scheme bug, executor driving rules, deterministic v5 UUID gap) + log entry Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 32 ++- AGENTS.md | 68 ++++++- README.md | 69 ++++++- docs/DESIGN.md | 237 +++++++++++++++++++--- docs/SPEC.allium | 3 + src/DaprConfig.scala | 7 +- src/js/Dapr.scala | 5 +- wiki/dapr/dapr-js-sdk.md | 31 +++ wiki/index.md | 4 +- wiki/log.md | 5 + wiki/scala-js/scala-js-async-jspi-wasm.md | 26 +++ 11 files changed, 436 insertions(+), 51 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7fdda6..815d4ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,27 @@ jobs: - name: Unit tests run: scala-cli test . --test-only 'dapr4s.test.unit.*' + test-js: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: coursier/cache-action@v8 + - uses: VirtusLab/scala-cli-setup@v1 + with: + power: true + # Scala.js invocations must exclude jvm-deps.scala (the Dapr Java SDK + + # testcontainers) — scala-cli cannot scope dependency directives to a platform, + # so that file is the wall keeping JVM-only artifacts out of the JS build. + # The runner's default Node is fine: unit tests run on the plain JS backend + # (the orphan-js.await capability code is Wasm-only and unit tests don't link + # it), and no unit test loads @dapr/dapr, so no npm install is needed here. + - name: Compile (Scala.js) + run: scala-cli compile --js . --exclude jvm-deps.scala + - name: Unit tests (Scala.js) + run: scala-cli test --js . --exclude jvm-deps.scala + integration-test: runs-on: ubuntu-latest steps: @@ -63,7 +84,7 @@ jobs: run: scala-cli test . --test-only 'dapr4s.test.integration.*' publish: - needs: [format, test, integration-test] + needs: [format, test, test-js, integration-test] if: github.event_name == 'push' runs-on: ubuntu-latest steps: @@ -81,3 +102,12 @@ jobs: PUBLISH_PASSWORD: ${{ secrets.PUBLISH_PASSWORD }} PUBLISH_SECRET_KEY: ${{ secrets.PUBLISH_SECRET_KEY }} PUBLISH_SECRET_KEY_PASSWORD: ${{ secrets.PUBLISH_SECRET_KEY_PASSWORD }} + # Second invocation publishes the Scala.js artifact (dapr4s_sjs1_3); excluding + # jvm-deps.scala keeps the Dapr Java SDK out of its POM. + - name: Publish (Scala.js) + run: scala-cli publish --js . --exclude jvm-deps.scala + 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/AGENTS.md b/AGENTS.md index fbbace6..97e209c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,17 @@ 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 +- **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 add `--js` **and + must pass `--exclude jvm-deps.scala`** (the file holding the JVM-only Dapr Java SDK + + testcontainers deps — scala-cli cannot scope dep directives to a platform, so excluding that file + is what keeps the `_sjs1_3` build/POM clean): + - `scala-cli compile --js . --exclude jvm-deps.scala` + - `scala-cli test --js . --exclude jvm-deps.scala` + Platform-specific sources carry per-file `//> using target.platform "jvm"`/`"scala-js"` + directives. `//> using jsEsVersionStr "es2017"` is required by `js.async`/`js.await`. +- **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). Run tests with `scala-cli test . --test-only "*unit*"` for unit tests. - **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`). @@ -36,6 +46,10 @@ Both must stay in sync with the code at all times. - **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.) + Cross deps use the `::version` (double-colon) form; `scala-java-time` provides `java.time` on + Scala.js (a thin JDK shim on the JVM). JVM-only deps (Dapr Java SDK, testcontainers) live in + `jvm-deps.scala`, not `project.scala`. The JS layer's npm dep (`@dapr/dapr`) is declared in + `package.json`. --- @@ -158,11 +172,35 @@ 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 `src/internal/` is marked `@scala.caps.assumeSafe`. There are two platform walls +behind the same boundary: + +- `src/internal/` (excluding the `js/` subdirectory, 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 `internal/` or in any test file. +- `src/internal/js/` (all js-tagged, same package `dapr4s.internal`) is the **JS wall**: the only + place `@dapr/dapr` (and express/Node) types may appear. The `@js.native` facades live in + `dapr4s.internal.facade` (`src/internal/js/facade/`). No `js.Promise`, facade type, or other JS + interop type may leak into the public API. + +The `@assumeSafe` boundary is the wall on both platforms — same rule, same documentation duty +(WHAT/WHY/WHY SAFE on every escape hatch). + +### Scala.js layer rules + +- **Orphan `js.await` ONLY via `JsAwait`** (`src/internal/js/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. +- **SDK signatures are verified against `node_modules` sources, never guessed.** The TypeScript + interfaces are erased; read the installed `@dapr/dapr`/`express` JS sources (and record findings + in `wiki/dapr/dapr-js-sdk.md`). +- Facade gotchas: **ports are strings** everywhere in the JS SDK; **`CommunicationProtocolEnum` is + numeric with `GRPC = 0`, `HTTP = 1`** — a facade defaulting to 0 silently picks gRPC. + `HttpMethod` values are lowercase strings. Options objects are `Partial<...>` — model them as + non-native `js.Object` traits/classes with `js.UndefOr` fields. --- @@ -194,7 +232,18 @@ output for parse errors and fails loudly to catch this. - **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.*'`. +- **Scala.js unit tests** (no Docker, no npm install needed): + `scala-cli test --js . --exclude jvm-deps.scala`. These run on the **plain JS backend** under + Node — fine because unit tests never link the orphan-await capability code and never load + `@dapr/dapr`. Most unit tests cross-compile and run on both platforms; the jvm-tagged + exceptions are `SubscriberTest`, `BindingDispatchTest`, `JobDispatchTest`, and + `DaprServerTestBase` (they drive the JVM `DaprAppServer` over real HTTP on + `com.sun.net.httpserver`), plus `TestCodecs.scala` (Jackson — a Java SDK transitive dep) and + `TestDaprExtensions.scala`. `TestCodecsJs.scala` provides the same codec given names over ujson + so the shared tests run unchanged on JS. +- **Integration tests** (require Docker, **JVM-only for now** — every file under + `test/integration/` is jvm-tagged; a Wasm+JSPI JS e2e is a documented follow-up): + `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 @@ -314,5 +363,6 @@ probing class files whenever you need to verify an API. - 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 JS integration-test suite in CI yet — the Scala.js layer is e2e-verified manually against a + real sidecar (see `docs/DESIGN.md`); the CI `test-js` job covers compilation and the shared unit + tests on the plain JS backend. diff --git a/README.md b/README.md index 35aa07e..f94d848 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,16 @@ 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 +- 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) +- JVM 25 (for the JVM platform) +- Node 25+ and `npm install @dapr/dapr` (only for running Scala.js apps that touch capabilities — see below) +- Docker (only for the integration tests, which spin up a real `daprd` sidecar + Redis via testcontainers) ## Build & test @@ -27,14 +29,23 @@ Java SDK is hidden entirely; users see only Scala types. 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 --js . --exclude jvm-deps.scala # Scala.js +scala-cli test --js . --exclude jvm-deps.scala # Scala.js unit tests ``` +Scala.js invocations must `--exclude jvm-deps.scala` (the file holding the JVM-only +Dapr Java SDK and testcontainers dependencies). + ## 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 +54,50 @@ 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 throw `UnsupportedOperationException` because the Dapr JS +SDK has no API for them (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 +``` + +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..6bfa354 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. 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/internal/` and `src/internal/js/` respectively) — see the [Scala.js platform](#scalajs-platform) section. + 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/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/internal/js/`): + +```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/internal/js/)" + 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/internal/`, the JS SDK's `DaprClient`/`DaprWorkflowClient`/`WorkflowRuntime` (plus express and raw `fetch`) in `src/internal/js/`. 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. --- @@ -529,27 +566,46 @@ Internal catch clauses use `scala.util.control.NonFatal` to ensure fatal JVM err ## Project Structure (Scala CLI) +Platform tags: files marked `[jvm]` / `[js]` carry a `//> using target.platform` directive and exist on one platform only; untagged sources cross-compile. + ``` 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 deps (Dapr Java SDK, testcontainers) — every Scala.js +│ # invocation passes --exclude jvm-deps.scala (see Scala.js platform section) +├── publish-conf.scala # CI publishing config (git:tag version, central, env credentials) +├── package.json # npm dep @dapr/dapr for the Scala.js layer (Node resolves it from the CWD) ├── src/ │ ├── Models.scala # Value types: StateEntry, ConfigurationItem, StateOp, SubscriptionResult, -│ │ # CloudEvent, InvokeRequest, WorkflowSnapshot/Status [safe mode] +│ │ # CloudEvent, InvokeRequest, WorkflowSnapshot/Status, Job/Conversation models │ ├── 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] +│ ├── DaprApp.scala # DaprApp case class + Subscription/InvokeRoute/BindingRoute/JobRoute │ ├── 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 +│ ├── 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, ... ) -│ └── internal/ +│ │ # SerializedJson, ApiToken, DaprPort, DaprDuration, PemPath, ...) +│ ├── derivation/ # Macro derivation layer: per-capability derive engines (State, Publish, +│ │ # Invoke, Secrets, Configuration, Bindings, Crypto, Jobs, Subscriptions, +│ │ # InvokeRoutes/BindingRoutes/JobRoutes, WorkflowActivities/-Calls, +│ │ # WorkflowEvents, Actor/ActorState/ActorDefinitions, Forwarders, MacroSupport) +│ ├── jvm/ +│ │ └── Dapr.scala # JVM entry point: class Dapr(config) with .run + .serve [@assumeSafe] [jvm] +│ ├── js/ +│ │ └── Dapr.scala # Scala.js entry point: same public run/serve signatures +│ │ # + JS-only runAsync/serveAsync [@assumeSafe] [js] +│ └── internal/ # JVM internal layer — Java SDK confined here [all jvm] │ ├── DaprCapabilityImpl.scala # DaprCapability implementation │ ├── MonoOps.scala # Reactor Mono → blocking bridge (.toFuture().get()) +│ ├── FluxOps.scala # Reactor Flux subscription bridge (configuration subscribe) │ ├── NullOps.scala # null-handling helpers +│ ├── Json.scala # shared Jackson mapper for internal protocol plumbing │ ├── DaprAppServer.scala # HTTP server (OpenJDK jdk.httpserver); workflow/actor registration │ ├── StateCapabilityImpl.scala │ ├── PublishCapabilityImpl.scala @@ -559,24 +615,57 @@ dapr4s/ │ ├── BindingsCapabilityImpl.scala │ ├── LockCapabilityImpl.scala │ ├── ActorCapabilityImpl.scala +│ ├── ConversationCapabilityImpl.scala +│ ├── CryptoCapabilityImpl.scala +│ ├── JobsCapabilityImpl.scala │ ├── HttpActorContext.scala │ ├── WorkflowCapabilityImpl.scala │ ├── WorkflowContextImpl.scala -│ └── WorkflowBridges.scala # WorkflowBridge / WorkflowActivityBridge (Java SDK adapters) +│ ├── WorkflowBridges.scala # WorkflowBridge / WorkflowActivityBridge (Java SDK adapters) +│ └── js/ # Scala.js internal layer — JS SDK confined here; +│ │ # same package dapr4s.internal [all js] +│ ├── facade/ # @js.native facades (package dapr4s.internal.facade): +│ │ ├── DaprSdk.scala # DaprClient + options/enums (@dapr/dapr root exports) +│ │ ├── WorkflowSdk.scala # DaprWorkflowClient, WorkflowRuntime, SdkTask, contexts +│ │ ├── Express.scala # express 4 app/request/response + http.Server, process +│ │ ├── NodeFetch.scala # Node-global fetch +│ │ └── NodeCrypto.scala # node:crypto createHash (deterministic newUuid) +│ ├── 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 +│ ├── StateCapabilityImpl.scala # … + Publish/Invoke/Secrets/Configuration/ +│ ├── ...CapabilityImpl.scala # Bindings/Lock/Crypto 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 + ├── TestCodecs.scala # shared test JsonCodec instances (Jackson) [jvm] + ├── TestCodecsJs.scala # same given names over ujson, so shared tests cross-run [js] + ├── TestDaprExtensions.scala # test-only Dapr.runWithEndpoints(http, grpc) helper [jvm] ├── TestOptionCodec.scala - ├── unit/ + ├── unit/ # cross-platform unless tagged │ ├── ModelsTest.scala │ ├── JsonCodecTest.scala - │ ├── CCTest.scala # capture checking invariants (ScopeContainment, JsonCodec) + │ ├── CharsetsTest.scala + │ ├── CCTest.scala # capture checking invariants (ScopeContainment, JsonCodec) + │ ├── DaprAppValidationTest.scala + │ ├── ActorDefinitionsTest.scala + │ ├── CapabilityDerivationTest.scala (+ CapabilityDerivationFixtures) + │ ├── InvokeDerivationTest.scala (+ DerivationFixtures) + │ ├── ServerRouteDerivationTest.scala + │ ├── WorkflowActivityDerivationTest.scala (+ WorkflowActivityDerivationFixtures) + │ ├── WorkflowEventsTest.scala │ ├── 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/ + │ ├── StateCapabilityTest.scala # superseded by CapabilityHandlerTest (kept as a tombstone note) + │ ├── DaprServerTestBase.scala # drives the JVM DaprAppServer over real HTTP [jvm] + │ ├── SubscriberTest.scala # DaprAppServer dispatch logic [jvm] + │ ├── BindingDispatchTest.scala # [jvm] + │ └── JobDispatchTest.scala # [jvm] + └── integration/ # all [jvm] — testcontainers + a real daprd sidecar ├── TestDaprApp.scala # In-process DaprApp dispatch helper for tests (@assumeSafe) ├── DaprTestContainer.scala # Testcontainers bridge ├── StateIntegrationTest.scala @@ -593,12 +682,16 @@ dapr4s/ ├── ActorCapabilityServerTest.scala ├── InvokeCapabilityServerTest.scala ├── WorkflowCapabilityServerTest.scala + ├── JobsCapabilityServerTest.scala + ├── CryptoCapabilityServerTest.scala + ├── ConversationCapabilityServerTest.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 + ├── OrderServiceMain.scala # @main entry point (serve OrderServiceApp()) [jvm] + ├── InventoryServiceMain.scala # [jvm] + ├── EchoServiceClient.scala ├── CounterActorApp.scala ├── CounterActorShared.scala ├── WorkflowApp.scala @@ -614,9 +707,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 `internal/`, JS SDK (`@dapr/dapr`) confined to `internal/js/` | 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 +827,98 @@ 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**, 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** — a clean failure mode, not a runtime surprise. + +### 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 | `UnsupportedOperationException` — the JS SDK has no jobs or conversation API; 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). + +### 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`. + +### Build pattern: `jvm-deps.scala` + `--exclude` + +scala-cli cannot scope dependency directives to a platform — a `//> using dep` applies to every platform of the build, even from a `target.platform jvm`-tagged file. The Dapr Java SDK and testcontainers therefore live in `jvm-deps.scala` at the repo root, which every Scala.js invocation excludes: + +```bash +scala-cli compile --js . --exclude jvm-deps.scala +scala-cli test --js . --exclude jvm-deps.scala +scala-cli publish --js . --exclude jvm-deps.scala +``` + +Default (JVM) invocations include the file, so JVM workflows are unchanged. This keeps the published `_sjs1_3` POM free of JVM-only artifacts. Platform-specific sources carry per-file `//> using target.platform` directives (see Project Structure). Building the JS platform requires scala-cli >= 1.13.0. + +### 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 | `UnsupportedOperationException` (absent from the JS SDK) | +| 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/SPEC.allium b/docs/SPEC.allium index a07d79f..9152657 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/internal/js/), equally + -- opaque to user code and equally managed inside @assumeSafe boundaries. } external entity Workflow { diff --git a/src/DaprConfig.scala b/src/DaprConfig.scala index 656b5b4..928ffa6 100644 --- a/src/DaprConfig.scala +++ b/src/DaprConfig.scala @@ -59,9 +59,10 @@ case class DaprConfig( * @param grpcTlsCaPath * Path to the CA certificate file (PEM) for server verification. Required when TLS is enabled. * - * Scala.js note: only `httpEndpoint`, `grpcEndpoint`, `apiToken`, `grpcMaxInboundMessageSizeBytes`, and `timeout` are - * honoured by the JS backend (the Dapr JS SDK exposes a much smaller knob set); the OkHttp/gRPC-Java transport - * settings are silently ignored there, and the TLS material paths are currently JVM-only. + * 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 diff --git a/src/js/Dapr.scala b/src/js/Dapr.scala index fb849d0..ddafa88 100644 --- a/src/js/Dapr.scala +++ b/src/js/Dapr.scala @@ -157,8 +157,9 @@ class Dapr(config: DaprConfig = DaprConfig()): * 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 on Scala.js is not implemented yet: a [[DaprApp]] with non-empty `workflows` or - * `activities` currently throws `UnsupportedOperationException` at startup (see `dapr4s.internal.WorkflowHost`). + * 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 diff --git a/wiki/dapr/dapr-js-sdk.md b/wiki/dapr/dapr-js-sdk.md index 2b1bb54..014ca2f 100644 --- a/wiki/dapr/dapr-js-sdk.md +++ b/wiki/dapr/dapr-js-sdk.md @@ -53,6 +53,22 @@ Sub-clients (readonly fields) and key signatures: 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 @@ -104,6 +120,21 @@ registerActor(cls); getRegisteredActors() - **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)). + +### 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). diff --git a/wiki/index.md b/wiki/index.md index a2cb934..894334e 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -97,7 +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, DaprServer, actors (class-name reflection hazard), workflows (async-generator model, *WithName variants), serialization/error rules, missing jobs/conversation | 2026-06-11 | +| [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), serialization/error rules, missing jobs/conversation | 2026-06-11 | | [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 | @@ -158,5 +158,5 @@ Compiling Scala 3 (including capture-checked dapr4s) to JavaScript/WebAssembly | 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`, dep-directive platform leak + jvm-deps.scala/--exclude pattern, `::` dep syntax, publish --cross (_3 + _sjs1_3), scala-cli >= 1.13.0 floor, cwd-based npm resolution, GH Actions | 2026-06-11 | -| [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 | 2026-06-11 | +| [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) | 2026-06-11 | | [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 1577e08..389efb7 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1,5 +1,10 @@ # Wiki Log +## [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 diff --git a/wiki/scala-js/scala-js-async-jspi-wasm.md b/wiki/scala-js/scala-js-async-jspi-wasm.md index 795a3a1..a8c36be 100644 --- a/wiki/scala-js/scala-js-async-jspi-wasm.md +++ b/wiki/scala-js/scala-js-async-jspi-wasm.md @@ -70,6 +70,32 @@ CI note: Node 23/24 flags must be argv flags on the node process (`NODE_OPTIONS` - 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/internal/js/`, 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. + ## See Also - [Cross-Building JVM + Scala.js with Scala CLI](scala-js-cross-building-scala-cli.md) — build/test/publish mechanics, `jsEmitWasm` directive From 5c544c9095ef2c311b1decce46de92af2b956f02 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Thu, 11 Jun 2026 04:50:07 +0200 Subject: [PATCH 06/17] fix: apply adversarial-review findings to the Scala.js layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - publish/invoke: falsy JSON scalar payloads (0, false, "", null) were silently dropped by the SDK's 'if (params?.body)' truthiness check — raw sidecar fetch fallback carries them; verified against daprd (redis XRANGE shows the exact documents arriving) - waitForCompletion: pass fractional seconds (sub-second timeouts no longer reject immediately) - bulkPublish: whole-request failures rethrow like the JVM instead of fabricating 'all entries failed' - URL-encode every interpolated path segment in dapr4s-owned raw-fetch URLs (actor invoke, getWithETag, HttpActorContext, new raw publish/invoke) - DaprAppServer: bind failure now closes the started WorkflowRuntime (leak kept the event loop alive and executed activities against a torn-down capability); WHY-SAFE comments softened accordingly - HttpActorContext.get: consume the response body on early returns - PemPath: JVM-only companion extension PemPath(java.nio.file.Path) - package.json: intentional private manifest, @dapr/dapr pinned exactly - AGENTS.md/DESIGN.md: correct the integration-tagging claim (apps/ fixtures cross-compile on purpose), JS-wall carve-outs for the entry points - scripts/k8s-test.sh: fix stale pre-rename main-class package names - comment/doc polish: HealthClient claim, empty-string-document caveat, TestCodecs assumeSafe rationale JS unit tests now also RUN green on Node: 142/142 (JVM: 166/166). Co-Authored-By: Claude Fable 5 --- AGENTS.md | 13 ++- docs/DESIGN.md | 2 +- package.json | 20 ++--- scripts/k8s-test.sh | 6 +- src/internal/js/ActorCapabilityImpl.scala | 13 ++- src/internal/js/BindingsCapabilityImpl.scala | 5 +- src/internal/js/DaprAppServer.scala | 25 +++++- src/internal/js/HttpActorContext.scala | 28 ++++--- src/internal/js/InvokeCapabilityImpl.scala | 82 ++++++++++++++----- src/internal/js/JsInterop.scala | 26 +++++- src/internal/js/PublishCapabilityImpl.scala | 83 +++++++++++++++++--- src/internal/js/StateCapabilityImpl.scala | 4 +- src/internal/js/WorkflowCapabilityImpl.scala | 5 +- src/internal/js/WorkflowHost.scala | 6 +- src/internal/js/facade/DaprSdk.scala | 4 +- src/js/Dapr.scala | 4 +- src/jvm/PemPathJvm.scala | 14 ++++ src/optypes/PemPath.scala | 5 +- test/TestCodecs.scala | 5 ++ test/TestCodecsJs.scala | 4 + 20 files changed, 279 insertions(+), 75 deletions(-) create mode 100644 src/jvm/PemPathJvm.scala diff --git a/AGENTS.md b/AGENTS.md index 97e209c..b5b5ee3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -182,7 +182,11 @@ behind the same boundary: - `src/internal/js/` (all js-tagged, same package `dapr4s.internal`) is the **JS wall**: the only place `@dapr/dapr` (and express/Node) types may appear. The `@js.native` facades live in `dapr4s.internal.facade` (`src/internal/js/facade/`). No `js.Promise`, facade type, or other JS - interop type may leak into the public API. + 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/facades 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). @@ -241,8 +245,11 @@ output for parse errors and fails loudly to catch this. `com.sun.net.httpserver`), plus `TestCodecs.scala` (Jackson — a Java SDK transitive dep) and `TestDaprExtensions.scala`. `TestCodecsJs.scala` provides the same codec given names over ujson so the shared tests run unchanged on JS. -- **Integration tests** (require Docker, **JVM-only for now** — every file under - `test/integration/` is jvm-tagged; a Wasm+JSPI JS e2e is a documented follow-up): +- **Integration tests** (require Docker, **JVM-only for now** — every suite/harness file directly + under `test/integration/` is jvm-tagged; a Wasm+JSPI JS e2e is a documented follow-up). The + `DaprApp` fixtures in `test/integration/apps/` (all except the two `*Main.scala` entry points) + are deliberately **untagged and cross-compile** — `CapabilityHandlerTest` exercises them on + Scala.js, so do not jvm-tag them. Run: `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()` diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 6bfa354..7e621ad 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -665,7 +665,7 @@ dapr4s/ │ ├── SubscriberTest.scala # DaprAppServer dispatch logic [jvm] │ ├── BindingDispatchTest.scala # [jvm] │ └── JobDispatchTest.scala # [jvm] - └── integration/ # all [jvm] — testcontainers + a real daprd sidecar + └── integration/ # suites all [jvm]; apps/ cross-compiles except the Mains ├── TestDaprApp.scala # In-process DaprApp dispatch helper for tests (@assumeSafe) ├── DaprTestContainer.scala # Testcontainers bridge ├── StateIntegrationTest.scala diff --git a/package.json b/package.json index bf4e79d..d28211b 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,9 @@ { - "name": "t3code-c916dd05", - "version": "1.0.0", - "description": "| CI | Release | | --- | --- | | [![Build Status][Badge-GitHubActions]][Link-GitHubActions] | [![Release Artifacts][Badge-MavenCentral]][Link-MavenCentral] |", - "main": "index.js", - "directories": { - "doc": "docs", - "test": "test" - }, - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "keywords": [], - "author": "", - "license": "ISC", + "name": "dapr4s", + "private": true, + "description": "npm dependency manifest for the dapr4s Scala.js layer — the facades in src/internal/js/facade/ are verified against exactly this @dapr/dapr version (TypeScript types are erased at runtime, so a floating range could drift undetected)", + "license": "Apache-2.0", "dependencies": { - "@dapr/dapr": "^3.18.0" + "@dapr/dapr": "3.18.0" } } 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/src/internal/js/ActorCapabilityImpl.scala b/src/internal/js/ActorCapabilityImpl.scala index 75d4655..81eb811 100644 --- a/src/internal/js/ActorCapabilityImpl.scala +++ b/src/internal/js/ActorCapabilityImpl.scala @@ -34,7 +34,10 @@ private[internal] final class ActorCapabilityImpl( import ActorCapabilityImpl.* private def methodUrl(method: ActorMethodName): String = - s"${httpBase(sidecar)}/v1.0/actors/${actorType.value}/${actorId.value}/method/${method.value}" + 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) @@ -51,6 +54,14 @@ private[internal] final class ActorCapabilityImpl( @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`). */ diff --git a/src/internal/js/BindingsCapabilityImpl.scala b/src/internal/js/BindingsCapabilityImpl.scala index 1b98125..abe60b2 100644 --- a/src/internal/js/BindingsCapabilityImpl.scala +++ b/src/internal/js/BindingsCapabilityImpl.scala @@ -19,7 +19,10 @@ private[internal] final class BindingsCapabilityImpl( 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. + // 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))) diff --git a/src/internal/js/DaprAppServer.scala b/src/internal/js/DaprAppServer.scala index 8bda908..55a11fd 100644 --- a/src/internal/js/DaprAppServer.scala +++ b/src/internal/js/DaprAppServer.scala @@ -352,7 +352,23 @@ private[dapr4s] final class DaprAppServer(app: DaprApp): }, ): Unit, ) - JsAwait.await(serverFailure) + 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: @@ -370,9 +386,10 @@ private object DaprAppServer: * 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; shutdown exits the process). Same - * erasure rationale as `ConfigurationCapabilityImpl.subscribe`'s callback cast and the `AnyRef`-erasure pattern - * documented in AGENTS.md. + * 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: facade.ExpressHandler^): facade.ExpressHandler = handler.asInstanceOf[facade.ExpressHandler] diff --git a/src/internal/js/HttpActorContext.scala b/src/internal/js/HttpActorContext.scala index 419f9c0..ca336c6 100644 --- a/src/internal/js/HttpActorContext.scala +++ b/src/internal/js/HttpActorContext.scala @@ -36,17 +36,23 @@ private[internal] final class HttpActorContext( 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"$base/v1.0/actors/${actorType.value}/${actorId.value}/state/${key.value}" + s"$actorPrefix/state/${ActorCapabilityImpl.urlSegment(key.value)}" private def bulkStateUrl: String = - s"$base/v1.0/actors/${actorType.value}/${actorId.value}/state" + s"$actorPrefix/state" private def reminderUrl(name: ReminderName): String = - s"$base/v1.0/actors/${actorType.value}/${actorId.value}/reminders/${name.value}" + s"$actorPrefix/reminders/${ActorCapabilityImpl.urlSegment(name.value)}" private def timerUrl(name: TimerName): String = - s"$base/v1.0/actors/${actorType.value}/${actorId.value}/timers/${name.value}" + s"$actorPrefix/timers/${ActorCapabilityImpl.urlSegment(name.value)}" // ---- State ----------------------------------------------------------------- @@ -54,14 +60,16 @@ private[internal] final class HttpActorContext( val url = stateUrl(key) val init = new facade.FetchRequestInit(method = "GET", headers = ActorCapabilityImpl.baseHeaders(sidecar)) val response = JsAwait.await(facade.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 - else - val text = JsAwait.await(response.text()) - // 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. - if code >= 400 then throw new RuntimeException(s"Dapr API error $code at $url: $text") - else summon[JsonCodec[T]].decode(text).toOption + // 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]( diff --git a/src/internal/js/InvokeCapabilityImpl.scala b/src/internal/js/InvokeCapabilityImpl.scala index b18bec4..cc96e20 100644 --- a/src/internal/js/InvokeCapabilityImpl.scala +++ b/src/internal/js/InvokeCapabilityImpl.scala @@ -8,6 +8,44 @@ import JsInterop.* @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. + val headers = ActorCapabilityImpl.baseHeaders(sidecar) + metadata.foreach { case (k, v) => headers(k.value) = v.value } + // fetch only auto-uppercases the six spec-listed methods (notably NOT "patch"), so uppercase + // explicitly like the SDK does (`params?.method.toLocaleUpperCase()`). + val init = new facade.FetchRequestInit(method = toJsMethod(httpMethod).toUpperCase, headers = headers, body = json) + val response = JsAwait.await(facade.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 SDK enum only declares get/delete/post/put/patch; `"head"` and `"options"` are still correct because the value @@ -38,24 +76,32 @@ private[internal] final class InvokeCapabilityImpl( httpMethod: HttpMethod = HttpMethod.Post, metadata: Map[MetadataKey, MetadataValue] = Map.empty, )[Resp: JsonCodec]: Resp = - // 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(metadata) - headers("Content-Type") = "application/json" - val response = JsAwait.await( - scope.client.invoker.invoke( - appId.value, - method.value, - toJsMethod(httpMethod), - parseJson(summon[JsonCodec[Req]].encode(data)), - new facade.InvokerOptions(headers = 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)) + 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(metadata) + headers("Content-Type") = "application/json" + val response = JsAwait.await( + scope.client.invoker.invoke( + appId.value, + method.value, + toJsMethod(httpMethod), + parsed, + new facade.InvokerOptions(headers = 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( diff --git a/src/internal/js/JsInterop.scala b/src/internal/js/JsInterop.scala index ceb5360..8b475f2 100644 --- a/src/internal/js/JsInterop.scala +++ b/src/internal/js/JsInterop.scala @@ -18,18 +18,42 @@ 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) + /** 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). + * artifact for an empty HTTP body — which, see [[jsonStringOrNull]], also swallows a response document that is the + * JSON empty string `""`). */ def isAbsent(v: js.Any): Boolean = js.isUndefined(v) || (v == null) || ((v: Any) match diff --git a/src/internal/js/PublishCapabilityImpl.scala b/src/internal/js/PublishCapabilityImpl.scala index 2c832c9..df7cdda 100644 --- a/src/internal/js/PublishCapabilityImpl.scala +++ b/src/internal/js/PublishCapabilityImpl.scala @@ -22,12 +22,21 @@ private[internal] final class PublishCapabilityImpl( // 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 response = JsAwait.await( - scope.client.pubsub.publish(pubsubName.value, topic.value, parseJson(json), jsonContentTypeOptions), - ) - throwIfFailed(response) + 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, parsed, jsonContentTypeOptions), + ) + throwIfFailed(response) def publishWithMetadata[T: JsonCodec]( topic: Topic, @@ -35,9 +44,13 @@ private[internal] final class PublishCapabilityImpl( metadata: Map[MetadataKey, MetadataValue], ): Unit = val json = summon[JsonCodec[T]].encode(data) - val options = new facade.PubSubPublishOptions(contentType = "application/json", metadata = toDict(metadata)) - val response = JsAwait.await(scope.client.pubsub.publish(pubsubName.value, topic.value, parseJson(json), options)) - throwIfFailed(response) + 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 = new facade.PubSubPublishOptions(contentType = "application/json", metadata = toDict(metadata)) + val response = JsAwait.await(scope.client.pubsub.publish(pubsubName.value, topic.value, 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 @@ -51,8 +64,24 @@ private[internal] final class PublishCapabilityImpl( ) }.toJSArray val response = JsAwait.await(scope.client.pubsub.publishBulk(pubsubName.value, topic.value, messages)) - val failedIds = response.failedMessages.toList.map(fm => BulkEntryId(fm.message.entryID)) - BulkPublishResult(failedIds) + 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: @@ -63,3 +92,39 @@ private object PublishCapabilityImpl: */ private def throwIfFailed(response: facade.SoftFailureResponse): 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 = new facade.FetchRequestInit( + method = "POST", + headers = ActorCapabilityImpl.baseHeaders(sidecar), + body = json, + ) + val response = JsAwait.await(facade.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/internal/js/StateCapabilityImpl.scala b/src/internal/js/StateCapabilityImpl.scala index a0c8090..2b36e97 100644 --- a/src/internal/js/StateCapabilityImpl.scala +++ b/src/internal/js/StateCapabilityImpl.scala @@ -114,8 +114,10 @@ private[internal] final class StateCapabilityImpl( // (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 url = s"${ActorCapabilityImpl.httpBase(scope.sidecar)}/v1.0/state/${storeName.value}/${key.value}$query" + val base = ActorCapabilityImpl.httpBase(scope.sidecar) + val url = s"$base/v1.0/state/${urlSegment(storeName.value)}/${urlSegment(key.value)}$query" val response = JsAwait.await( facade.NodeGlobals.fetch( url, diff --git a/src/internal/js/WorkflowCapabilityImpl.scala b/src/internal/js/WorkflowCapabilityImpl.scala index 20719f4..e80cd67 100644 --- a/src/internal/js/WorkflowCapabilityImpl.scala +++ b/src/internal/js/WorkflowCapabilityImpl.scala @@ -63,8 +63,11 @@ private[internal] final class WorkflowCapabilityImpl( // 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.toSeconds.toDouble)) + 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 diff --git a/src/internal/js/WorkflowHost.scala b/src/internal/js/WorkflowHost.scala index 631695f..880373c 100644 --- a/src/internal/js/WorkflowHost.scala +++ b/src/internal/js/WorkflowHost.scala @@ -71,8 +71,10 @@ private[internal] object WorkflowHost: // 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 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), 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). + // 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 => diff --git a/src/internal/js/facade/DaprSdk.scala b/src/internal/js/facade/DaprSdk.scala index 858c290..8c1a6aa 100644 --- a/src/internal/js/facade/DaprSdk.scala +++ b/src/internal/js/facade/DaprSdk.scala @@ -334,7 +334,9 @@ private[internal] final class DecryptRequest( ) extends js.Object // --------------------------------------------------------------------------- -// health (interfaces/Client/IClientHealth.ts) — used by the serve() phase +// health (interfaces/Client/IClientHealth.ts) — declared for completeness of +// the client seam; dapr4s does not currently call it (the SDK's sub-clients +// await sidecar health themselves on first use) // --------------------------------------------------------------------------- @js.native diff --git a/src/js/Dapr.scala b/src/js/Dapr.scala index ddafa88..a9dd82e 100644 --- a/src/js/Dapr.scala +++ b/src/js/Dapr.scala @@ -157,8 +157,8 @@ class Dapr(config: DaprConfig = DaprConfig()): * 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 + * 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 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/optypes/PemPath.scala b/src/optypes/PemPath.scala index ae73a68..a6d98b1 100644 --- a/src/optypes/PemPath.scala +++ b/src/optypes/PemPath.scala @@ -5,8 +5,9 @@ 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, convert with - * `PemPath(path.toString)` or `java.nio.file.Path.of(pemPath.value)`. + * 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. */ diff --git a/test/TestCodecs.scala b/test/TestCodecs.scala index 97bce1f..f87c85d 100644 --- a/test/TestCodecs.scala +++ b/test/TestCodecs.scala @@ -13,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/TestCodecsJs.scala b/test/TestCodecsJs.scala index 1b99b11..bee63b4 100644 --- a/test/TestCodecsJs.scala +++ b/test/TestCodecsJs.scala @@ -9,6 +9,10 @@ package dapr4s // 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. From ec89910ca7498934cfa31253619d96acd4bbb12d Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Thu, 11 Jun 2026 22:56:11 +0200 Subject: [PATCH 07/17] refactor!: shared/jvm/js source layout, platform traits, scoped deps files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/{shared,jvm,js} + test/{shared,jvm,js} layout (git mv, history kept); packages unchanged; per-file target.platform directives still do the scoping - jobs/conversation: compile-time absence instead of UnsupportedOperationException — DaprCapability extends DaprCapabilityPlatform (jvm: jobs+conversation with ^{this}; js: empty), companion extends DaprCapabilityCompanionPlatform; JobsCapability/ConversationCapability + their models + Jobs.derive engine + job forwarders moved to src/jvm; calling them from JS code no longer compiles - platform-scoped dependency files replace --exclude entirely: a using dep in a target.platform-tagged file applies only to that platform (empirically verified incl. clean POMs and --cross publish); testcontainers moved to jvm-test-deps.test.scala (.test.scala gives test scope; test.dep is NOT platform-scoped); js-deps.scala placeholder for the ScalablyTyped deps - test split: jobs/conversation model+derivation tests → test/jvm (JVM 166/166 green, JS 132/132 green; both platforms compile clean from scratch, JS classpath free of dapr-sdk/testcontainers) Known nightly compiler bug worked around: importing language.experimental.safe in the split-out jvm model files breaks unrelated @assumeSafe enums on clean builds; the files stay out of safe mode with documented rationale. Co-Authored-By: Claude Fable 5 --- .scalafmt.conf | 35 ++-- AGENTS.md | 14 +- js-deps.scala | 7 + jvm-deps.scala | 27 +-- jvm-test-deps.test.scala | 16 ++ project.scala | 14 +- src/js/DaprCapabilityPlatform.scala | 24 +++ src/js/derivation/ForwardersPlatform.scala | 14 ++ .../internal}/ActorCapabilityImpl.scala | 0 .../internal}/BindingsCapabilityImpl.scala | 0 .../ConfigurationCapabilityImpl.scala | 0 .../internal}/CryptoCapabilityImpl.scala | 0 .../js => js/internal}/DaprAppServer.scala | 0 .../internal}/DaprCapabilityImpl.scala | 12 +- .../js => js/internal}/HttpActorContext.scala | 0 .../internal}/InvokeCapabilityImpl.scala | 0 .../js => js/internal}/JsAwait.scala | 0 .../js => js/internal}/JsInterop.scala | 0 .../internal}/LockCapabilityImpl.scala | 0 .../internal}/PublishCapabilityImpl.scala | 0 .../internal}/SecretsCapabilityImpl.scala | 0 .../internal}/StateCapabilityImpl.scala | 0 .../internal}/WorkflowCapabilityImpl.scala | 0 .../internal}/WorkflowContextImpl.scala | 0 .../internal}/WorkflowCoroutine.scala | 0 .../js => js/internal}/WorkflowHost.scala | 0 .../js => js/internal}/facade/DaprSdk.scala | 0 .../js => js/internal}/facade/Express.scala | 0 .../internal}/facade/NodeCrypto.scala | 0 .../js => js/internal}/facade/NodeFetch.scala | 0 .../internal}/facade/WorkflowSdk.scala | 0 src/jvm/ConversationCapability.scala | 44 +++++ src/jvm/ConversationModels.scala | 134 ++++++++++++++ src/jvm/DaprCapabilityPlatform.scala | 50 ++++++ src/jvm/JobsCapability.scala | 79 +++++++++ src/jvm/JobsModels.scala | 63 +++++++ src/jvm/derivation/ForwardersPlatform.scala | 45 +++++ src/{ => jvm}/derivation/Jobs.scala | 1 + .../internal/ActorCapabilityImpl.scala | 0 .../internal/BindingsCapabilityImpl.scala | 0 .../ConfigurationCapabilityImpl.scala | 0 .../internal/ConversationCapabilityImpl.scala | 0 .../internal/CryptoCapabilityImpl.scala | 0 src/{ => jvm}/internal/DaprAppServer.scala | 0 .../internal/DaprCapabilityImpl.scala | 0 src/{ => jvm}/internal/FluxOps.scala | 0 src/{ => jvm}/internal/HttpActorContext.scala | 0 .../internal/InvokeCapabilityImpl.scala | 0 .../internal/JobsCapabilityImpl.scala | 0 src/{ => jvm}/internal/Json.scala | 0 .../internal/LockCapabilityImpl.scala | 0 src/{ => jvm}/internal/MonoOps.scala | 0 src/{ => jvm}/internal/NullOps.scala | 0 .../internal/PublishCapabilityImpl.scala | 0 .../internal/SecretsCapabilityImpl.scala | 0 .../internal/StateCapabilityImpl.scala | 0 src/{ => jvm}/internal/WorkflowBridges.scala | 0 .../internal/WorkflowCapabilityImpl.scala | 0 .../internal/WorkflowContextImpl.scala | 0 src/{ => shared}/Actors.scala | 0 src/{ => shared}/Capabilities.scala | 116 ------------ src/{ => shared}/Charsets.scala | 0 src/{ => shared}/DaprApp.scala | 0 src/{ => shared}/DaprCapability.scala | 29 ++- src/{ => shared}/DaprConfig.scala | 0 src/{ => shared}/Exceptions.scala | 0 src/{ => shared}/JsonCodec.scala | 0 src/{ => shared}/Models.scala | 166 ------------------ src/{ => shared}/Validation.scala | 0 src/{ => shared}/Workflows.scala | 0 src/{ => shared}/derivation/Actor.scala | 0 .../derivation/ActorDefinitions.scala | 0 src/{ => shared}/derivation/ActorState.scala | 0 .../derivation/BindingRoutes.scala | 0 src/{ => shared}/derivation/Bindings.scala | 0 .../derivation/Configuration.scala | 0 src/{ => shared}/derivation/Crypto.scala | 0 src/{ => shared}/derivation/Forwarders.scala | 38 +--- src/{ => shared}/derivation/Invoke.scala | 0 .../derivation/InvokeDerivationRuntime.scala | 0 .../derivation/InvokeRoutes.scala | 0 src/{ => shared}/derivation/JobRoutes.scala | 0 .../derivation/MacroSupport.scala | 0 src/{ => shared}/derivation/Publish.scala | 0 src/{ => shared}/derivation/Secrets.scala | 0 src/{ => shared}/derivation/State.scala | 0 .../derivation/Subscriptions.scala | 0 src/{ => shared}/derivation/Workflow.scala | 0 .../derivation/WorkflowActivities.scala | 0 .../derivation/WorkflowActivityCalls.scala | 0 .../derivation/WorkflowEvents.scala | 0 src/{ => shared}/derivation/annotations.scala | 0 src/{ => shared}/derivation/name.scala | 0 src/{ => shared}/optypes/ActivityName.scala | 0 src/{ => shared}/optypes/ActorId.scala | 0 .../optypes/ActorMethodName.scala | 0 src/{ => shared}/optypes/ActorStateKey.scala | 0 src/{ => shared}/optypes/ActorType.scala | 0 src/{ => shared}/optypes/ApiToken.scala | 0 src/{ => shared}/optypes/AppId.scala | 0 src/{ => shared}/optypes/BindingName.scala | 0 .../optypes/BindingOperation.scala | 0 src/{ => shared}/optypes/BulkEntryId.scala | 0 src/{ => shared}/optypes/CloudEventId.scala | 0 .../optypes/CloudEventSource.scala | 0 .../optypes/CloudEventSpecVersion.scala | 0 src/{ => shared}/optypes/CloudEventType.scala | 0 .../optypes/ConfigurationKey.scala | 0 .../optypes/ConfigurationStoreName.scala | 0 .../optypes/ConfigurationValue.scala | 0 .../optypes/ConfigurationVersion.scala | 0 src/{ => shared}/optypes/ContentType.scala | 0 .../optypes/ConversationComponentName.scala | 0 .../optypes/ConversationContextId.scala | 0 .../optypes/CryptoComponentName.scala | 0 src/{ => shared}/optypes/CryptoKeyName.scala | 0 src/{ => shared}/optypes/DaprDuration.scala | 0 src/{ => shared}/optypes/DaprPort.scala | 0 src/{ => shared}/optypes/ETag.scala | 0 src/{ => shared}/optypes/EventName.scala | 0 .../optypes/InvokeMethodName.scala | 0 src/{ => shared}/optypes/JobName.scala | 0 .../optypes/KeyWrapAlgorithm.scala | 0 src/{ => shared}/optypes/LockOwner.scala | 0 src/{ => shared}/optypes/LockResourceId.scala | 0 src/{ => shared}/optypes/LockStoreName.scala | 0 src/{ => shared}/optypes/MetadataKey.scala | 0 src/{ => shared}/optypes/MetadataValue.scala | 0 src/{ => shared}/optypes/ModelName.scala | 0 src/{ => shared}/optypes/PemPath.scala | 0 src/{ => shared}/optypes/PubSubName.scala | 0 src/{ => shared}/optypes/ReminderName.scala | 0 src/{ => shared}/optypes/Route.scala | 0 src/{ => shared}/optypes/SecretKey.scala | 0 .../optypes/SecretStoreName.scala | 0 src/{ => shared}/optypes/SecretValue.scala | 0 src/{ => shared}/optypes/SerializedJson.scala | 0 .../optypes/StateConcurrency.scala | 0 .../optypes/StateConsistency.scala | 0 src/{ => shared}/optypes/StateQuery.scala | 0 src/{ => shared}/optypes/StateStoreKey.scala | 0 src/{ => shared}/optypes/StateStoreName.scala | 0 src/{ => shared}/optypes/TimerName.scala | 0 src/{ => shared}/optypes/ToolCallId.scala | 0 src/{ => shared}/optypes/ToolName.scala | 0 src/{ => shared}/optypes/Topic.scala | 0 .../optypes/WorkflowInstanceId.scala | 0 src/{ => shared}/optypes/WorkflowName.scala | 0 test/{ => js}/TestCodecsJs.scala | 0 test/{ => jvm}/TestCodecs.scala | 0 test/{ => jvm}/TestDaprExtensions.scala | 0 .../apps/InventoryServiceMain.scala | 0 .../apps/OrderServiceMain.scala | 0 .../ActorCapabilityServerTest.scala | 0 .../ConversationCapabilityServerTest.scala | 0 .../CryptoCapabilityServerTest.scala | 0 .../integration/DaprTestContainer.scala | 0 .../integration/EndToEndIntegrationTest.scala | 0 .../InventoryServiceIntegrationTest.scala | 0 .../InvokeCapabilityServerTest.scala | 0 .../integration/InvokeIntegrationTest.scala | 0 .../JobsCapabilityServerTest.scala | 0 .../LockCapabilityServerTest.scala | 0 .../OrderServiceIntegrationTest.scala | 0 .../integration/PubSubIntegrationTest.scala | 0 .../PublishCapabilityServerTest.scala | 0 .../SecretsCapabilityServerTest.scala | 0 .../integration/SecretsIntegrationTest.scala | 0 .../StateCapabilityServerTest.scala | 0 .../integration/StateIntegrationTest.scala | 0 test/{ => jvm}/integration/TestDaprApp.scala | 0 .../WorkflowCapabilityServerTest.scala | 0 test/{ => jvm}/unit/BindingDispatchTest.scala | 0 test/{ => jvm}/unit/DaprServerTestBase.scala | 0 test/{ => jvm}/unit/JobDispatchTest.scala | 0 .../JvmCapabilityDerivationFixtures.scala | 43 +++++ .../unit/JvmCapabilityDerivationTest.scala | 21 +++ test/jvm/unit/JvmModelsTest.scala | 81 +++++++++ .../unit/JvmServerRouteDerivationTest.scala | 30 ++++ test/{ => jvm}/unit/SubscriberTest.scala | 0 test/{ => shared}/TestOptionCodec.scala | 0 .../apps/CounterActorApp.scala | 0 .../apps/CounterActorShared.scala | 0 .../apps/EchoServiceClient.scala | 0 .../apps/InventoryServiceApp.scala | 0 .../apps/OrderServiceApp.scala | 0 .../{integration => shared}/apps/Shared.scala | 0 .../apps/TestDurations.scala | 0 .../apps/TestUpickleCodec.scala | 0 .../apps/WorkflowApp.scala | 0 .../unit/ActorDefinitionsTest.scala | 0 test/{ => shared}/unit/CCTest.scala | 0 .../unit/CapabilityDerivationFixtures.scala | 27 +-- .../unit/CapabilityDerivationTest.scala | 9 +- .../unit/CapabilityHandlerTest.scala | 0 test/{ => shared}/unit/CharsetsTest.scala | 0 .../unit/DaprAppValidationTest.scala | 0 .../unit/DerivationFixtures.scala | 0 .../unit/InvokeDerivationTest.scala | 0 test/{ => shared}/unit/JsonCodecTest.scala | 0 test/{ => shared}/unit/ModelsTest.scala | 61 +------ .../unit/ServerRouteDerivationTest.scala | 19 +- .../unit/StateCapabilityTest.scala | 0 .../WorkflowActivityDerivationFixtures.scala | 0 .../unit/WorkflowActivityDerivationTest.scala | 0 .../unit/WorkflowEventsTest.scala | 0 206 files changed, 727 insertions(+), 492 deletions(-) create mode 100644 js-deps.scala create mode 100644 jvm-test-deps.test.scala create mode 100644 src/js/DaprCapabilityPlatform.scala create mode 100644 src/js/derivation/ForwardersPlatform.scala rename src/{internal/js => js/internal}/ActorCapabilityImpl.scala (100%) rename src/{internal/js => js/internal}/BindingsCapabilityImpl.scala (100%) rename src/{internal/js => js/internal}/ConfigurationCapabilityImpl.scala (100%) rename src/{internal/js => js/internal}/CryptoCapabilityImpl.scala (100%) rename src/{internal/js => js/internal}/DaprAppServer.scala (100%) rename src/{internal/js => js/internal}/DaprCapabilityImpl.scala (95%) rename src/{internal/js => js/internal}/HttpActorContext.scala (100%) rename src/{internal/js => js/internal}/InvokeCapabilityImpl.scala (100%) rename src/{internal/js => js/internal}/JsAwait.scala (100%) rename src/{internal/js => js/internal}/JsInterop.scala (100%) rename src/{internal/js => js/internal}/LockCapabilityImpl.scala (100%) rename src/{internal/js => js/internal}/PublishCapabilityImpl.scala (100%) rename src/{internal/js => js/internal}/SecretsCapabilityImpl.scala (100%) rename src/{internal/js => js/internal}/StateCapabilityImpl.scala (100%) rename src/{internal/js => js/internal}/WorkflowCapabilityImpl.scala (100%) rename src/{internal/js => js/internal}/WorkflowContextImpl.scala (100%) rename src/{internal/js => js/internal}/WorkflowCoroutine.scala (100%) rename src/{internal/js => js/internal}/WorkflowHost.scala (100%) rename src/{internal/js => js/internal}/facade/DaprSdk.scala (100%) rename src/{internal/js => js/internal}/facade/Express.scala (100%) rename src/{internal/js => js/internal}/facade/NodeCrypto.scala (100%) rename src/{internal/js => js/internal}/facade/NodeFetch.scala (100%) rename src/{internal/js => js/internal}/facade/WorkflowSdk.scala (100%) create mode 100644 src/jvm/ConversationCapability.scala create mode 100644 src/jvm/ConversationModels.scala create mode 100644 src/jvm/DaprCapabilityPlatform.scala create mode 100644 src/jvm/JobsCapability.scala create mode 100644 src/jvm/JobsModels.scala create mode 100644 src/jvm/derivation/ForwardersPlatform.scala rename src/{ => jvm}/derivation/Jobs.scala (99%) rename src/{ => jvm}/internal/ActorCapabilityImpl.scala (100%) rename src/{ => jvm}/internal/BindingsCapabilityImpl.scala (100%) rename src/{ => jvm}/internal/ConfigurationCapabilityImpl.scala (100%) rename src/{ => jvm}/internal/ConversationCapabilityImpl.scala (100%) rename src/{ => jvm}/internal/CryptoCapabilityImpl.scala (100%) rename src/{ => jvm}/internal/DaprAppServer.scala (100%) rename src/{ => jvm}/internal/DaprCapabilityImpl.scala (100%) rename src/{ => jvm}/internal/FluxOps.scala (100%) rename src/{ => jvm}/internal/HttpActorContext.scala (100%) rename src/{ => jvm}/internal/InvokeCapabilityImpl.scala (100%) rename src/{ => jvm}/internal/JobsCapabilityImpl.scala (100%) rename src/{ => jvm}/internal/Json.scala (100%) rename src/{ => jvm}/internal/LockCapabilityImpl.scala (100%) rename src/{ => jvm}/internal/MonoOps.scala (100%) rename src/{ => jvm}/internal/NullOps.scala (100%) rename src/{ => jvm}/internal/PublishCapabilityImpl.scala (100%) rename src/{ => jvm}/internal/SecretsCapabilityImpl.scala (100%) rename src/{ => jvm}/internal/StateCapabilityImpl.scala (100%) rename src/{ => jvm}/internal/WorkflowBridges.scala (100%) rename src/{ => jvm}/internal/WorkflowCapabilityImpl.scala (100%) rename src/{ => jvm}/internal/WorkflowContextImpl.scala (100%) rename src/{ => shared}/Actors.scala (100%) rename src/{ => shared}/Capabilities.scala (85%) rename src/{ => shared}/Charsets.scala (100%) rename src/{ => shared}/DaprApp.scala (100%) rename src/{ => shared}/DaprCapability.scala (86%) rename src/{ => shared}/DaprConfig.scala (100%) rename src/{ => shared}/Exceptions.scala (100%) rename src/{ => shared}/JsonCodec.scala (100%) rename src/{ => shared}/Models.scala (55%) rename src/{ => shared}/Validation.scala (100%) rename src/{ => shared}/Workflows.scala (100%) rename src/{ => shared}/derivation/Actor.scala (100%) rename src/{ => shared}/derivation/ActorDefinitions.scala (100%) rename src/{ => shared}/derivation/ActorState.scala (100%) rename src/{ => shared}/derivation/BindingRoutes.scala (100%) rename src/{ => shared}/derivation/Bindings.scala (100%) rename src/{ => shared}/derivation/Configuration.scala (100%) rename src/{ => shared}/derivation/Crypto.scala (100%) rename src/{ => shared}/derivation/Forwarders.scala (92%) rename src/{ => shared}/derivation/Invoke.scala (100%) rename src/{ => shared}/derivation/InvokeDerivationRuntime.scala (100%) rename src/{ => shared}/derivation/InvokeRoutes.scala (100%) rename src/{ => shared}/derivation/JobRoutes.scala (100%) rename src/{ => shared}/derivation/MacroSupport.scala (100%) rename src/{ => shared}/derivation/Publish.scala (100%) rename src/{ => shared}/derivation/Secrets.scala (100%) rename src/{ => shared}/derivation/State.scala (100%) rename src/{ => shared}/derivation/Subscriptions.scala (100%) rename src/{ => shared}/derivation/Workflow.scala (100%) rename src/{ => shared}/derivation/WorkflowActivities.scala (100%) rename src/{ => shared}/derivation/WorkflowActivityCalls.scala (100%) rename src/{ => shared}/derivation/WorkflowEvents.scala (100%) rename src/{ => shared}/derivation/annotations.scala (100%) rename src/{ => shared}/derivation/name.scala (100%) rename src/{ => shared}/optypes/ActivityName.scala (100%) rename src/{ => shared}/optypes/ActorId.scala (100%) rename src/{ => shared}/optypes/ActorMethodName.scala (100%) rename src/{ => shared}/optypes/ActorStateKey.scala (100%) rename src/{ => shared}/optypes/ActorType.scala (100%) rename src/{ => shared}/optypes/ApiToken.scala (100%) rename src/{ => shared}/optypes/AppId.scala (100%) rename src/{ => shared}/optypes/BindingName.scala (100%) rename src/{ => shared}/optypes/BindingOperation.scala (100%) rename src/{ => shared}/optypes/BulkEntryId.scala (100%) rename src/{ => shared}/optypes/CloudEventId.scala (100%) rename src/{ => shared}/optypes/CloudEventSource.scala (100%) rename src/{ => shared}/optypes/CloudEventSpecVersion.scala (100%) rename src/{ => shared}/optypes/CloudEventType.scala (100%) rename src/{ => shared}/optypes/ConfigurationKey.scala (100%) rename src/{ => shared}/optypes/ConfigurationStoreName.scala (100%) rename src/{ => shared}/optypes/ConfigurationValue.scala (100%) rename src/{ => shared}/optypes/ConfigurationVersion.scala (100%) rename src/{ => shared}/optypes/ContentType.scala (100%) rename src/{ => shared}/optypes/ConversationComponentName.scala (100%) rename src/{ => shared}/optypes/ConversationContextId.scala (100%) rename src/{ => shared}/optypes/CryptoComponentName.scala (100%) rename src/{ => shared}/optypes/CryptoKeyName.scala (100%) rename src/{ => shared}/optypes/DaprDuration.scala (100%) rename src/{ => shared}/optypes/DaprPort.scala (100%) rename src/{ => shared}/optypes/ETag.scala (100%) rename src/{ => shared}/optypes/EventName.scala (100%) rename src/{ => shared}/optypes/InvokeMethodName.scala (100%) rename src/{ => shared}/optypes/JobName.scala (100%) rename src/{ => shared}/optypes/KeyWrapAlgorithm.scala (100%) rename src/{ => shared}/optypes/LockOwner.scala (100%) rename src/{ => shared}/optypes/LockResourceId.scala (100%) rename src/{ => shared}/optypes/LockStoreName.scala (100%) rename src/{ => shared}/optypes/MetadataKey.scala (100%) rename src/{ => shared}/optypes/MetadataValue.scala (100%) rename src/{ => shared}/optypes/ModelName.scala (100%) rename src/{ => shared}/optypes/PemPath.scala (100%) rename src/{ => shared}/optypes/PubSubName.scala (100%) rename src/{ => shared}/optypes/ReminderName.scala (100%) rename src/{ => shared}/optypes/Route.scala (100%) rename src/{ => shared}/optypes/SecretKey.scala (100%) rename src/{ => shared}/optypes/SecretStoreName.scala (100%) rename src/{ => shared}/optypes/SecretValue.scala (100%) rename src/{ => shared}/optypes/SerializedJson.scala (100%) rename src/{ => shared}/optypes/StateConcurrency.scala (100%) rename src/{ => shared}/optypes/StateConsistency.scala (100%) rename src/{ => shared}/optypes/StateQuery.scala (100%) rename src/{ => shared}/optypes/StateStoreKey.scala (100%) rename src/{ => shared}/optypes/StateStoreName.scala (100%) rename src/{ => shared}/optypes/TimerName.scala (100%) rename src/{ => shared}/optypes/ToolCallId.scala (100%) rename src/{ => shared}/optypes/ToolName.scala (100%) rename src/{ => shared}/optypes/Topic.scala (100%) rename src/{ => shared}/optypes/WorkflowInstanceId.scala (100%) rename src/{ => shared}/optypes/WorkflowName.scala (100%) rename test/{ => js}/TestCodecsJs.scala (100%) rename test/{ => jvm}/TestCodecs.scala (100%) rename test/{ => jvm}/TestDaprExtensions.scala (100%) rename test/{integration => jvm}/apps/InventoryServiceMain.scala (100%) rename test/{integration => jvm}/apps/OrderServiceMain.scala (100%) rename test/{ => jvm}/integration/ActorCapabilityServerTest.scala (100%) rename test/{ => jvm}/integration/ConversationCapabilityServerTest.scala (100%) rename test/{ => jvm}/integration/CryptoCapabilityServerTest.scala (100%) rename test/{ => jvm}/integration/DaprTestContainer.scala (100%) rename test/{ => jvm}/integration/EndToEndIntegrationTest.scala (100%) rename test/{ => jvm}/integration/InventoryServiceIntegrationTest.scala (100%) rename test/{ => jvm}/integration/InvokeCapabilityServerTest.scala (100%) rename test/{ => jvm}/integration/InvokeIntegrationTest.scala (100%) rename test/{ => jvm}/integration/JobsCapabilityServerTest.scala (100%) rename test/{ => jvm}/integration/LockCapabilityServerTest.scala (100%) rename test/{ => jvm}/integration/OrderServiceIntegrationTest.scala (100%) rename test/{ => jvm}/integration/PubSubIntegrationTest.scala (100%) rename test/{ => jvm}/integration/PublishCapabilityServerTest.scala (100%) rename test/{ => jvm}/integration/SecretsCapabilityServerTest.scala (100%) rename test/{ => jvm}/integration/SecretsIntegrationTest.scala (100%) rename test/{ => jvm}/integration/StateCapabilityServerTest.scala (100%) rename test/{ => jvm}/integration/StateIntegrationTest.scala (100%) rename test/{ => jvm}/integration/TestDaprApp.scala (100%) rename test/{ => jvm}/integration/WorkflowCapabilityServerTest.scala (100%) rename test/{ => jvm}/unit/BindingDispatchTest.scala (100%) rename test/{ => jvm}/unit/DaprServerTestBase.scala (100%) rename test/{ => jvm}/unit/JobDispatchTest.scala (100%) create mode 100644 test/jvm/unit/JvmCapabilityDerivationFixtures.scala create mode 100644 test/jvm/unit/JvmCapabilityDerivationTest.scala create mode 100644 test/jvm/unit/JvmModelsTest.scala create mode 100644 test/jvm/unit/JvmServerRouteDerivationTest.scala rename test/{ => jvm}/unit/SubscriberTest.scala (100%) rename test/{ => shared}/TestOptionCodec.scala (100%) rename test/{integration => shared}/apps/CounterActorApp.scala (100%) rename test/{integration => shared}/apps/CounterActorShared.scala (100%) rename test/{integration => shared}/apps/EchoServiceClient.scala (100%) rename test/{integration => shared}/apps/InventoryServiceApp.scala (100%) rename test/{integration => shared}/apps/OrderServiceApp.scala (100%) rename test/{integration => shared}/apps/Shared.scala (100%) rename test/{integration => shared}/apps/TestDurations.scala (100%) rename test/{integration => shared}/apps/TestUpickleCodec.scala (100%) rename test/{integration => shared}/apps/WorkflowApp.scala (100%) rename test/{ => shared}/unit/ActorDefinitionsTest.scala (100%) rename test/{ => shared}/unit/CCTest.scala (100%) rename test/{ => shared}/unit/CapabilityDerivationFixtures.scala (90%) rename test/{ => shared}/unit/CapabilityDerivationTest.scala (89%) rename test/{ => shared}/unit/CapabilityHandlerTest.scala (100%) rename test/{ => shared}/unit/CharsetsTest.scala (100%) rename test/{ => shared}/unit/DaprAppValidationTest.scala (100%) rename test/{ => shared}/unit/DerivationFixtures.scala (100%) rename test/{ => shared}/unit/InvokeDerivationTest.scala (100%) rename test/{ => shared}/unit/JsonCodecTest.scala (100%) rename test/{ => shared}/unit/ModelsTest.scala (79%) rename test/{ => shared}/unit/ServerRouteDerivationTest.scala (88%) rename test/{ => shared}/unit/StateCapabilityTest.scala (100%) rename test/{ => shared}/unit/WorkflowActivityDerivationFixtures.scala (100%) rename test/{ => shared}/unit/WorkflowActivityDerivationTest.scala (100%) rename test/{ => shared}/unit/WorkflowEventsTest.scala (100%) diff --git a/.scalafmt.conf b/.scalafmt.conf index a7e33ba..6525c8a 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -10,22 +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", - "src/internal/js/ConfigurationCapabilityImpl\\.scala", - "src/internal/js/DaprAppServer\\.scala", - "src/internal/js/DaprCapabilityImpl\\.scala", - "src/internal/js/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 b5b5ee3..c60befe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,12 +24,12 @@ Both must stay in sync with the code at all times. available nightly to get the latest capture-checking and safe-mode fixes. Update when the build tool hints that a newer nightly is available. - **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 add `--js` **and - must pass `--exclude jvm-deps.scala`** (the file holding the JVM-only Dapr Java SDK + - testcontainers deps — scala-cli cannot scope dep directives to a platform, so excluding that file - is what keeps the `_sjs1_3` build/POM clean): - - `scala-cli compile --js . --exclude jvm-deps.scala` - - `scala-cli test --js . --exclude jvm-deps.scala` + 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`, scoped to the JVM by their + `target.platform` directive, which is what keeps the `_sjs1_3` build/POM clean — no `--exclude` + flags needed): + - `scala-cli compile --js .` + - `scala-cli test --js .` Platform-specific sources carry per-file `//> using target.platform "jvm"`/`"scala-js"` directives. `//> using jsEsVersionStr "es2017"` is required by `js.async`/`js.await`. - **Build tool**: Scala CLI (`project.scala` using directives). **scala-cli >= 1.13.0 is required @@ -237,7 +237,7 @@ output for parse errors and fails loudly to catch this. across `JsonCodecTest`, `ModelsTest`, `StateCapabilityTest`, `CCTest`, `SubscriberTest`, `BindingDispatchTest`, `CapabilityHandlerTest` (with `DaprServerTestBase` as a shared helper). - **Scala.js unit tests** (no Docker, no npm install needed): - `scala-cli test --js . --exclude jvm-deps.scala`. These run on the **plain JS backend** under + `scala-cli test --js .`. These run on the **plain JS backend** under Node — fine because unit tests never link the orphan-await capability code and never load `@dapr/dapr`. Most unit tests cross-compile and run on both platforms; the jvm-tagged exceptions are `SubscriberTest`, `BindingDispatchTest`, `JobDispatchTest`, and diff --git a/js-deps.scala b/js-deps.scala new file mode 100644 index 0000000..e2fe2ea --- /dev/null +++ b/js-deps.scala @@ -0,0 +1,7 @@ +//> using target.platform "scala-js" +// Scala.js-only main-scope dependencies, the JS twin of jvm-deps.scala: the `target.platform` +// directive above scopes any `using dep` in this file to the Scala.js platform, so JVM builds +// never resolve them. +// +// No deps yet — the ScalablyTyped-generated facade dependencies (replacing the hand-written +// facades in src/js/internal/facade/) land here in the next phase. diff --git a/jvm-deps.scala b/jvm-deps.scala index a42f921..254fb69 100644 --- a/jvm-deps.scala +++ b/jvm-deps.scala @@ -1,24 +1,15 @@ -// JVM-only dependencies, kept out of project.scala on purpose. +//> using target.platform "jvm" +// JVM-only main-scope dependencies (the Dapr Java SDK), kept out of project.scala on purpose. // -// scala-cli cannot scope dependency directives to a platform: a `//> using dep` directive -// applies to every platform of the build no matter which file it appears in (even a file -// tagged `//> using target.platform jvm`). Keeping the Dapr Java SDK and testcontainers here -// and excluding this file from Scala.js invocations is what keeps the published _sjs1_3 POM -// free of JVM-only artifacts. +// 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 anywhere any more.) // -// Default invocations (`scala-cli compile|test|publish .`) include this file, so the JVM -// workflow is unchanged. Every Scala.js invocation must exclude it: -// -// scala-cli compile --js . --exclude jvm-deps.scala -// scala-cli test --js . --exclude jvm-deps.scala -// scala-cli publish --js . --exclude jvm-deps.scala +// 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" -//> 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). 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/project.scala b/project.scala index 9b71359..f89a58e 100644 --- a/project.scala +++ b/project.scala @@ -13,16 +13,18 @@ // Safe mode is enabled per-file via: import language.experimental.safe // // Platforms: "jvm" is listed first, so plain `scala-cli compile/test .` builds the JVM -// platform; select Scala.js with `--js` (and add `--exclude jvm-deps.scala`, see below). +// platform; select Scala.js with `--js` (no extra flags needed). // jsEsVersionStr es2017 is required by js.async/js.await (used by the JS internal layer). // -// JVM-only dependencies (the Dapr Java SDK and testcontainers) live in jvm-deps.scala, NOT -// here: scala-cli has no platform-scoped dependency directives (deps declared in a -// `//> using target.platform jvm` file still leak into the Scala.js build and would pollute -// the published _sjs1_3 POM). JS invocations exclude that file: `--exclude jvm-deps.scala`. +// Platform-specific dependencies live in dedicated files, scoped by a `target.platform` +// directive (a `using dep` in a platform-tagged file applies only to that platform): +// - 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 main-scope deps (facades land here) +// Only cross-platform deps belong in this file (the `::version` double-colon form). // // scala-java-time provides java.time on Scala.js (java.time.Instant is part of the public -// JobsCapability/Models API); on the JVM it is a thin shim over the JDK and harmless. +// WorkflowSnapshot/Models API); on the JVM it is a thin shim over the JDK and harmless. //> using dep "io.github.cquiroz::scala-java-time::2.6.0" //> using test.dep "org.scalameta::munit::1.3.0" //> using test.dep "com.lihaoyi::upickle::3.3.1" 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/internal/js/ActorCapabilityImpl.scala b/src/js/internal/ActorCapabilityImpl.scala similarity index 100% rename from src/internal/js/ActorCapabilityImpl.scala rename to src/js/internal/ActorCapabilityImpl.scala diff --git a/src/internal/js/BindingsCapabilityImpl.scala b/src/js/internal/BindingsCapabilityImpl.scala similarity index 100% rename from src/internal/js/BindingsCapabilityImpl.scala rename to src/js/internal/BindingsCapabilityImpl.scala diff --git a/src/internal/js/ConfigurationCapabilityImpl.scala b/src/js/internal/ConfigurationCapabilityImpl.scala similarity index 100% rename from src/internal/js/ConfigurationCapabilityImpl.scala rename to src/js/internal/ConfigurationCapabilityImpl.scala diff --git a/src/internal/js/CryptoCapabilityImpl.scala b/src/js/internal/CryptoCapabilityImpl.scala similarity index 100% rename from src/internal/js/CryptoCapabilityImpl.scala rename to src/js/internal/CryptoCapabilityImpl.scala diff --git a/src/internal/js/DaprAppServer.scala b/src/js/internal/DaprAppServer.scala similarity index 100% rename from src/internal/js/DaprAppServer.scala rename to src/js/internal/DaprAppServer.scala diff --git a/src/internal/js/DaprCapabilityImpl.scala b/src/js/internal/DaprCapabilityImpl.scala similarity index 95% rename from src/internal/js/DaprCapabilityImpl.scala rename to src/js/internal/DaprCapabilityImpl.scala index 4295d96..b475bb6 100644 --- a/src/internal/js/DaprCapabilityImpl.scala +++ b/src/js/internal/DaprCapabilityImpl.scala @@ -96,15 +96,9 @@ private[dapr4s] final class DaprCapabilityImpl( def crypto(componentName: CryptoComponentName): CryptoCapability^{this} = new CryptoCapabilityImpl(this, componentName).asInstanceOf[CryptoCapability] - def jobs: JobsCapability^{this} = - throw new UnsupportedOperationException( - "the Dapr JS SDK (@dapr/dapr 3.x) has no jobs API; use dapr4s on the JVM for the jobs capability", - ) - - def conversation(componentName: ConversationComponentName): ConversationCapability^{this} = - throw new UnsupportedOperationException( - "the Dapr JS SDK (@dapr/dapr 3.x) has no conversation API; use dapr4s on the JVM for the conversation capability", - ) + // 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: diff --git a/src/internal/js/HttpActorContext.scala b/src/js/internal/HttpActorContext.scala similarity index 100% rename from src/internal/js/HttpActorContext.scala rename to src/js/internal/HttpActorContext.scala diff --git a/src/internal/js/InvokeCapabilityImpl.scala b/src/js/internal/InvokeCapabilityImpl.scala similarity index 100% rename from src/internal/js/InvokeCapabilityImpl.scala rename to src/js/internal/InvokeCapabilityImpl.scala diff --git a/src/internal/js/JsAwait.scala b/src/js/internal/JsAwait.scala similarity index 100% rename from src/internal/js/JsAwait.scala rename to src/js/internal/JsAwait.scala diff --git a/src/internal/js/JsInterop.scala b/src/js/internal/JsInterop.scala similarity index 100% rename from src/internal/js/JsInterop.scala rename to src/js/internal/JsInterop.scala diff --git a/src/internal/js/LockCapabilityImpl.scala b/src/js/internal/LockCapabilityImpl.scala similarity index 100% rename from src/internal/js/LockCapabilityImpl.scala rename to src/js/internal/LockCapabilityImpl.scala diff --git a/src/internal/js/PublishCapabilityImpl.scala b/src/js/internal/PublishCapabilityImpl.scala similarity index 100% rename from src/internal/js/PublishCapabilityImpl.scala rename to src/js/internal/PublishCapabilityImpl.scala diff --git a/src/internal/js/SecretsCapabilityImpl.scala b/src/js/internal/SecretsCapabilityImpl.scala similarity index 100% rename from src/internal/js/SecretsCapabilityImpl.scala rename to src/js/internal/SecretsCapabilityImpl.scala diff --git a/src/internal/js/StateCapabilityImpl.scala b/src/js/internal/StateCapabilityImpl.scala similarity index 100% rename from src/internal/js/StateCapabilityImpl.scala rename to src/js/internal/StateCapabilityImpl.scala diff --git a/src/internal/js/WorkflowCapabilityImpl.scala b/src/js/internal/WorkflowCapabilityImpl.scala similarity index 100% rename from src/internal/js/WorkflowCapabilityImpl.scala rename to src/js/internal/WorkflowCapabilityImpl.scala diff --git a/src/internal/js/WorkflowContextImpl.scala b/src/js/internal/WorkflowContextImpl.scala similarity index 100% rename from src/internal/js/WorkflowContextImpl.scala rename to src/js/internal/WorkflowContextImpl.scala diff --git a/src/internal/js/WorkflowCoroutine.scala b/src/js/internal/WorkflowCoroutine.scala similarity index 100% rename from src/internal/js/WorkflowCoroutine.scala rename to src/js/internal/WorkflowCoroutine.scala diff --git a/src/internal/js/WorkflowHost.scala b/src/js/internal/WorkflowHost.scala similarity index 100% rename from src/internal/js/WorkflowHost.scala rename to src/js/internal/WorkflowHost.scala diff --git a/src/internal/js/facade/DaprSdk.scala b/src/js/internal/facade/DaprSdk.scala similarity index 100% rename from src/internal/js/facade/DaprSdk.scala rename to src/js/internal/facade/DaprSdk.scala diff --git a/src/internal/js/facade/Express.scala b/src/js/internal/facade/Express.scala similarity index 100% rename from src/internal/js/facade/Express.scala rename to src/js/internal/facade/Express.scala diff --git a/src/internal/js/facade/NodeCrypto.scala b/src/js/internal/facade/NodeCrypto.scala similarity index 100% rename from src/internal/js/facade/NodeCrypto.scala rename to src/js/internal/facade/NodeCrypto.scala diff --git a/src/internal/js/facade/NodeFetch.scala b/src/js/internal/facade/NodeFetch.scala similarity index 100% rename from src/internal/js/facade/NodeFetch.scala rename to src/js/internal/facade/NodeFetch.scala diff --git a/src/internal/js/facade/WorkflowSdk.scala b/src/js/internal/facade/WorkflowSdk.scala similarity index 100% rename from src/internal/js/facade/WorkflowSdk.scala rename to src/js/internal/facade/WorkflowSdk.scala 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/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/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 100% rename from src/internal/ActorCapabilityImpl.scala rename to src/jvm/internal/ActorCapabilityImpl.scala diff --git a/src/internal/BindingsCapabilityImpl.scala b/src/jvm/internal/BindingsCapabilityImpl.scala similarity index 100% rename from src/internal/BindingsCapabilityImpl.scala rename to src/jvm/internal/BindingsCapabilityImpl.scala diff --git a/src/internal/ConfigurationCapabilityImpl.scala b/src/jvm/internal/ConfigurationCapabilityImpl.scala similarity index 100% rename from src/internal/ConfigurationCapabilityImpl.scala rename to src/jvm/internal/ConfigurationCapabilityImpl.scala diff --git a/src/internal/ConversationCapabilityImpl.scala b/src/jvm/internal/ConversationCapabilityImpl.scala similarity index 100% rename from src/internal/ConversationCapabilityImpl.scala rename to src/jvm/internal/ConversationCapabilityImpl.scala diff --git a/src/internal/CryptoCapabilityImpl.scala b/src/jvm/internal/CryptoCapabilityImpl.scala similarity index 100% rename from src/internal/CryptoCapabilityImpl.scala rename to src/jvm/internal/CryptoCapabilityImpl.scala diff --git a/src/internal/DaprAppServer.scala b/src/jvm/internal/DaprAppServer.scala similarity index 100% rename from src/internal/DaprAppServer.scala rename to src/jvm/internal/DaprAppServer.scala diff --git a/src/internal/DaprCapabilityImpl.scala b/src/jvm/internal/DaprCapabilityImpl.scala similarity index 100% rename from src/internal/DaprCapabilityImpl.scala rename to src/jvm/internal/DaprCapabilityImpl.scala diff --git a/src/internal/FluxOps.scala b/src/jvm/internal/FluxOps.scala similarity index 100% rename from src/internal/FluxOps.scala rename to src/jvm/internal/FluxOps.scala diff --git a/src/internal/HttpActorContext.scala b/src/jvm/internal/HttpActorContext.scala similarity index 100% rename from src/internal/HttpActorContext.scala rename to src/jvm/internal/HttpActorContext.scala diff --git a/src/internal/InvokeCapabilityImpl.scala b/src/jvm/internal/InvokeCapabilityImpl.scala similarity index 100% rename from src/internal/InvokeCapabilityImpl.scala rename to src/jvm/internal/InvokeCapabilityImpl.scala diff --git a/src/internal/JobsCapabilityImpl.scala b/src/jvm/internal/JobsCapabilityImpl.scala similarity index 100% rename from src/internal/JobsCapabilityImpl.scala rename to src/jvm/internal/JobsCapabilityImpl.scala diff --git a/src/internal/Json.scala b/src/jvm/internal/Json.scala similarity index 100% rename from src/internal/Json.scala rename to src/jvm/internal/Json.scala diff --git a/src/internal/LockCapabilityImpl.scala b/src/jvm/internal/LockCapabilityImpl.scala similarity index 100% rename from src/internal/LockCapabilityImpl.scala rename to src/jvm/internal/LockCapabilityImpl.scala diff --git a/src/internal/MonoOps.scala b/src/jvm/internal/MonoOps.scala similarity index 100% rename from src/internal/MonoOps.scala rename to src/jvm/internal/MonoOps.scala diff --git a/src/internal/NullOps.scala b/src/jvm/internal/NullOps.scala similarity index 100% rename from src/internal/NullOps.scala rename to src/jvm/internal/NullOps.scala diff --git a/src/internal/PublishCapabilityImpl.scala b/src/jvm/internal/PublishCapabilityImpl.scala similarity index 100% rename from src/internal/PublishCapabilityImpl.scala rename to src/jvm/internal/PublishCapabilityImpl.scala diff --git a/src/internal/SecretsCapabilityImpl.scala b/src/jvm/internal/SecretsCapabilityImpl.scala similarity index 100% rename from src/internal/SecretsCapabilityImpl.scala rename to src/jvm/internal/SecretsCapabilityImpl.scala diff --git a/src/internal/StateCapabilityImpl.scala b/src/jvm/internal/StateCapabilityImpl.scala similarity index 100% rename from src/internal/StateCapabilityImpl.scala rename to src/jvm/internal/StateCapabilityImpl.scala diff --git a/src/internal/WorkflowBridges.scala b/src/jvm/internal/WorkflowBridges.scala similarity index 100% rename from src/internal/WorkflowBridges.scala rename to src/jvm/internal/WorkflowBridges.scala diff --git a/src/internal/WorkflowCapabilityImpl.scala b/src/jvm/internal/WorkflowCapabilityImpl.scala similarity index 100% rename from src/internal/WorkflowCapabilityImpl.scala rename to src/jvm/internal/WorkflowCapabilityImpl.scala diff --git a/src/internal/WorkflowContextImpl.scala b/src/jvm/internal/WorkflowContextImpl.scala similarity index 100% rename from src/internal/WorkflowContextImpl.scala rename to src/jvm/internal/WorkflowContextImpl.scala 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 100% rename from src/DaprConfig.scala rename to src/shared/DaprConfig.scala 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/optypes/PemPath.scala b/src/shared/optypes/PemPath.scala similarity index 100% rename from src/optypes/PemPath.scala rename to src/shared/optypes/PemPath.scala 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/TestCodecsJs.scala b/test/js/TestCodecsJs.scala similarity index 100% rename from test/TestCodecsJs.scala rename to test/js/TestCodecsJs.scala diff --git a/test/TestCodecs.scala b/test/jvm/TestCodecs.scala similarity index 100% rename from test/TestCodecs.scala rename to test/jvm/TestCodecs.scala diff --git a/test/TestDaprExtensions.scala b/test/jvm/TestDaprExtensions.scala similarity index 100% rename from test/TestDaprExtensions.scala rename to test/jvm/TestDaprExtensions.scala diff --git a/test/integration/apps/InventoryServiceMain.scala b/test/jvm/apps/InventoryServiceMain.scala similarity index 100% rename from test/integration/apps/InventoryServiceMain.scala rename to test/jvm/apps/InventoryServiceMain.scala diff --git a/test/integration/apps/OrderServiceMain.scala b/test/jvm/apps/OrderServiceMain.scala similarity index 100% rename from test/integration/apps/OrderServiceMain.scala rename to test/jvm/apps/OrderServiceMain.scala diff --git a/test/integration/ActorCapabilityServerTest.scala b/test/jvm/integration/ActorCapabilityServerTest.scala similarity index 100% rename from test/integration/ActorCapabilityServerTest.scala rename to test/jvm/integration/ActorCapabilityServerTest.scala diff --git a/test/integration/ConversationCapabilityServerTest.scala b/test/jvm/integration/ConversationCapabilityServerTest.scala similarity index 100% rename from test/integration/ConversationCapabilityServerTest.scala rename to test/jvm/integration/ConversationCapabilityServerTest.scala diff --git a/test/integration/CryptoCapabilityServerTest.scala b/test/jvm/integration/CryptoCapabilityServerTest.scala similarity index 100% rename from test/integration/CryptoCapabilityServerTest.scala rename to test/jvm/integration/CryptoCapabilityServerTest.scala diff --git a/test/integration/DaprTestContainer.scala b/test/jvm/integration/DaprTestContainer.scala similarity index 100% rename from test/integration/DaprTestContainer.scala rename to test/jvm/integration/DaprTestContainer.scala diff --git a/test/integration/EndToEndIntegrationTest.scala b/test/jvm/integration/EndToEndIntegrationTest.scala similarity index 100% rename from test/integration/EndToEndIntegrationTest.scala rename to test/jvm/integration/EndToEndIntegrationTest.scala diff --git a/test/integration/InventoryServiceIntegrationTest.scala b/test/jvm/integration/InventoryServiceIntegrationTest.scala similarity index 100% rename from test/integration/InventoryServiceIntegrationTest.scala rename to test/jvm/integration/InventoryServiceIntegrationTest.scala diff --git a/test/integration/InvokeCapabilityServerTest.scala b/test/jvm/integration/InvokeCapabilityServerTest.scala similarity index 100% rename from test/integration/InvokeCapabilityServerTest.scala rename to test/jvm/integration/InvokeCapabilityServerTest.scala diff --git a/test/integration/InvokeIntegrationTest.scala b/test/jvm/integration/InvokeIntegrationTest.scala similarity index 100% rename from test/integration/InvokeIntegrationTest.scala rename to test/jvm/integration/InvokeIntegrationTest.scala diff --git a/test/integration/JobsCapabilityServerTest.scala b/test/jvm/integration/JobsCapabilityServerTest.scala similarity index 100% rename from test/integration/JobsCapabilityServerTest.scala rename to test/jvm/integration/JobsCapabilityServerTest.scala diff --git a/test/integration/LockCapabilityServerTest.scala b/test/jvm/integration/LockCapabilityServerTest.scala similarity index 100% rename from test/integration/LockCapabilityServerTest.scala rename to test/jvm/integration/LockCapabilityServerTest.scala diff --git a/test/integration/OrderServiceIntegrationTest.scala b/test/jvm/integration/OrderServiceIntegrationTest.scala similarity index 100% rename from test/integration/OrderServiceIntegrationTest.scala rename to test/jvm/integration/OrderServiceIntegrationTest.scala diff --git a/test/integration/PubSubIntegrationTest.scala b/test/jvm/integration/PubSubIntegrationTest.scala similarity index 100% rename from test/integration/PubSubIntegrationTest.scala rename to test/jvm/integration/PubSubIntegrationTest.scala diff --git a/test/integration/PublishCapabilityServerTest.scala b/test/jvm/integration/PublishCapabilityServerTest.scala similarity index 100% rename from test/integration/PublishCapabilityServerTest.scala rename to test/jvm/integration/PublishCapabilityServerTest.scala diff --git a/test/integration/SecretsCapabilityServerTest.scala b/test/jvm/integration/SecretsCapabilityServerTest.scala similarity index 100% rename from test/integration/SecretsCapabilityServerTest.scala rename to test/jvm/integration/SecretsCapabilityServerTest.scala diff --git a/test/integration/SecretsIntegrationTest.scala b/test/jvm/integration/SecretsIntegrationTest.scala similarity index 100% rename from test/integration/SecretsIntegrationTest.scala rename to test/jvm/integration/SecretsIntegrationTest.scala diff --git a/test/integration/StateCapabilityServerTest.scala b/test/jvm/integration/StateCapabilityServerTest.scala similarity index 100% rename from test/integration/StateCapabilityServerTest.scala rename to test/jvm/integration/StateCapabilityServerTest.scala diff --git a/test/integration/StateIntegrationTest.scala b/test/jvm/integration/StateIntegrationTest.scala similarity index 100% rename from test/integration/StateIntegrationTest.scala rename to test/jvm/integration/StateIntegrationTest.scala diff --git a/test/integration/TestDaprApp.scala b/test/jvm/integration/TestDaprApp.scala similarity index 100% rename from test/integration/TestDaprApp.scala rename to test/jvm/integration/TestDaprApp.scala diff --git a/test/integration/WorkflowCapabilityServerTest.scala b/test/jvm/integration/WorkflowCapabilityServerTest.scala similarity index 100% rename from test/integration/WorkflowCapabilityServerTest.scala rename to test/jvm/integration/WorkflowCapabilityServerTest.scala diff --git a/test/unit/BindingDispatchTest.scala b/test/jvm/unit/BindingDispatchTest.scala similarity index 100% rename from test/unit/BindingDispatchTest.scala rename to test/jvm/unit/BindingDispatchTest.scala diff --git a/test/unit/DaprServerTestBase.scala b/test/jvm/unit/DaprServerTestBase.scala similarity index 100% rename from test/unit/DaprServerTestBase.scala rename to test/jvm/unit/DaprServerTestBase.scala diff --git a/test/unit/JobDispatchTest.scala b/test/jvm/unit/JobDispatchTest.scala similarity index 100% rename from test/unit/JobDispatchTest.scala rename to test/jvm/unit/JobDispatchTest.scala 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 100% rename from test/unit/SubscriberTest.scala rename to test/jvm/unit/SubscriberTest.scala 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/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 From 99e7b81f1f5d666b45c5d7c6ade89cbe62550c23 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Fri, 12 Jun 2026 00:11:30 +0200 Subject: [PATCH 08/17] feat!: ScalablyTyped-generated facades replace the hand-written ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Scala facade over @dapr/dapr is now generated by the ScalablyTyped converter as a build step (never committed): - scripts/generate-st-facades.sh: pinned stc invocation (org.scalablytyped.converter:cli_3:1.0.0-beta45, --scala 3.3.6 --scalajs 1.21.0 -s es2022) publishing to ivy2Local; idempotent warm skip; guards that its digests agree with js-deps.scala - js-deps.scala: org.scalablytyped::dapr__dapr::3.18.0-d1e27c + express + node deps (platform-scoped via target.platform); digests are deterministic from package-lock.json + converter version + flags - package.json: @types/express + @types/node as top-level deps (converter input), typescript devDep; package-lock.json now committed (digest determinism + CI cache key) - src/js rewritten against typings.*: all 16 internal impls + Dapr.scala; 5 hand facades deleted; the one survivor is the express default-import shim (ST's express() is not callable under Node ESM) - documented TS-vs-wire mismatches preserved with casts (IEtag {value} vs plain string, stop(): void returning a Promise, getBulk row shape) and the ESM rule: deep @dapr/dapr module specifiers don't resolve under Node ESM — values only via root re-exports (LockStatus pinned numerically) Verified: clean-build compile on both platforms (zero warnings), JVM 166/166, JS unit 132/132, and a 9/9 Wasm+JSPI smoke against daprd 1.17 (state/etag/publish incl. falsy fallback/invoke/workflow/configuration-gRPC). Co-Authored-By: Claude Fable 5 --- .gitignore | 5 +- AGENTS.md | 69 +- js-deps.scala | 27 +- package-lock.json | 1609 +++++++++++++++++ package.json | 9 +- scripts/generate-st-facades.sh | 86 + src/js/Dapr.scala | 12 +- src/js/internal/ActorCapabilityImpl.scala | 30 +- .../ConfigurationCapabilityImpl.scala | 58 +- src/js/internal/CryptoCapabilityImpl.scala | 52 +- src/js/internal/DaprAppServer.scala | 216 ++- src/js/internal/DaprCapabilityImpl.scala | 72 +- src/js/internal/HttpActorContext.scala | 22 +- src/js/internal/InvokeCapabilityImpl.scala | 71 +- src/js/internal/JsInterop.scala | 38 +- src/js/internal/LockCapabilityImpl.scala | 26 +- src/js/internal/PublishCapabilityImpl.scala | 69 +- src/js/internal/StateCapabilityImpl.scala | 175 +- src/js/internal/WorkflowCapabilityImpl.scala | 40 +- src/js/internal/WorkflowContextImpl.scala | 35 +- src/js/internal/WorkflowCoroutine.scala | 6 +- src/js/internal/WorkflowHost.scala | 50 +- src/js/internal/facade/DaprSdk.scala | 344 ---- src/js/internal/facade/Express.scala | 169 -- src/js/internal/facade/ExpressModule.scala | 70 + src/js/internal/facade/NodeCrypto.scala | 25 - src/js/internal/facade/NodeFetch.scala | 37 - src/js/internal/facade/WorkflowSdk.scala | 177 -- 28 files changed, 2504 insertions(+), 1095 deletions(-) create mode 100644 package-lock.json create mode 100755 scripts/generate-st-facades.sh delete mode 100644 src/js/internal/facade/DaprSdk.scala delete mode 100644 src/js/internal/facade/Express.scala create mode 100644 src/js/internal/facade/ExpressModule.scala delete mode 100644 src/js/internal/facade/NodeCrypto.scala delete mode 100644 src/js/internal/facade/NodeFetch.scala delete mode 100644 src/js/internal/facade/WorkflowSdk.scala diff --git a/.gitignore b/.gitignore index 3bbb64c..97aac30 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .claude node_modules/ -package-lock.json +# 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/AGENTS.md b/AGENTS.md index c60befe..160a436 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,9 @@ Both must stay in sync with the code at all times. - `scala-cli test --js .` Platform-specific sources carry per-file `//> using target.platform "jvm"`/`"scala-js"` directives. `//> 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. - **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). Run tests with `scala-cli test . --test-only "*unit*"` for unit tests. @@ -48,8 +51,10 @@ Both must stay in sync with the code at all times. both testcontainers deps are `test.dep` only — they are 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). JVM-only deps (Dapr Java SDK, testcontainers) live in - `jvm-deps.scala`, not `project.scala`. The JS layer's npm dep (`@dapr/dapr`) is declared in - `package.json`. + `jvm-deps.scala`, not `project.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); the generated facade jars are declared in + `js-deps.scala`. --- @@ -179,32 +184,60 @@ behind the same boundary: - `src/internal/` (excluding the `js/` subdirectory, 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 `internal/` or in any test file. -- `src/internal/js/` (all js-tagged, same package `dapr4s.internal`) is the **JS wall**: the only - place `@dapr/dapr` (and express/Node) types may appear. The `@js.native` facades live in - `dapr4s.internal.facade` (`src/internal/js/facade/`). No `js.Promise`, facade 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/facades 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. +- `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** (`typings.daprDapr`, `typings.expressServeStaticCore`, + `typings.node`, ... — see js-deps.scala), plus the single surviving hand-written shim in + `dapr4s.internal.facade` (`src/js/internal/facade/ExpressModule.scala`). No `js.Promise`, + `typings.*` 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). ### Scala.js layer rules -- **Orphan `js.await` ONLY via `JsAwait`** (`src/internal/js/JsAwait.scala`) — the single home of +- **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. -- **SDK signatures are verified against `node_modules` sources, never guessed.** The TypeScript - interfaces are erased; read the installed `@dapr/dapr`/`express` JS sources (and record findings - in `wiki/dapr/dapr-js-sdk.md`). -- Facade gotchas: **ports are strings** everywhere in the JS SDK; **`CommunicationProtocolEnum` is - numeric with `GRPC = 0`, `HTTP = 1`** — a facade defaulting to 0 silently picks gRPC. - `HttpMethod` values are lowercase strings. Options objects are `Partial<...>` — model them as - non-native `js.Object` traits/classes with `js.UndefOr` fields. +- **Facades are ScalablyTyped-generated**, not hand-written. `scripts/generate-st-facades.sh` + converts the npm packages pinned in `package.json` into `typings.*` jars in `~/.ivy2/local` + (run it once per machine; idempotent, fast skip when the jars exist). `js-deps.scala` pins the + resulting `org.scalablytyped::::-` coordinates; 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`) 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 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 `typings.*` 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 `typings.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(...)`). --- diff --git a/js-deps.scala b/js-deps.scala index e2fe2ea..f922071 100644 --- a/js-deps.scala +++ b/js-deps.scala @@ -3,5 +3,28 @@ // directive above scopes any `using dep` in this file to the Scala.js platform, so JVM builds // never resolve them. // -// No deps yet — the ScalablyTyped-generated facade dependencies (replacing the hand-written -// facades in src/js/internal/facade/) land here in the next phase. +// ==ScalablyTyped-generated facades== +// +// The three 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. They are published into the LOCAL ivy +// repository (~/.ivy2/local/org.scalablytyped/...) — never to a remote repository and never +// committed — so every machine (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. `-d1e27c`) is the converter's deterministic +// digest of (package-lock.json contents, converter version, converter flags). 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: the published dapr4s _sjs1_3 POM references these org.scalablytyped +// coordinates. They do not exist on Maven Central, so downstream Scala.js users must run the +// same generation (same package-lock.json, same converter version + flags — all shipped in +// this repository) to materialise them in their own ivy2Local before depending on dapr4s JS. +// +//> using dep "org.scalablytyped::dapr__dapr::3.18.0-d1e27c" +//> using dep "org.scalablytyped::express::4.17.21-bf7291" +//> using dep "org.scalablytyped::node::22.13.0-22253f" 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 index d28211b..aa7f6a9 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,14 @@ { "name": "dapr4s", "private": true, - "description": "npm dependency manifest for the dapr4s Scala.js layer — the facades in src/internal/js/facade/ are verified against exactly this @dapr/dapr version (TypeScript types are erased at runtime, so a floating range could drift undetected)", + "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" + "@dapr/dapr": "3.18.0", + "@types/express": "4.17.21", + "@types/node": "22.13.0" + }, + "devDependencies": { + "typescript": "5.7.3" } } diff --git a/scripts/generate-st-facades.sh b/scripts/generate-st-facades.sh new file mode 100755 index 0000000..d735471 --- /dev/null +++ b/scripts/generate-st-facades.sh @@ -0,0 +1,86 @@ +#!/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; 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. +# +# 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). +# 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 expected coordinates (npmVersion-digest), kept in lockstep with js-deps.scala. +EXPECTED_DAPR="3.18.0-d1e27c" +EXPECTED_EXPRESS="4.17.21-bf7291" +EXPECTED_NODE="22.13.0-22253f" + +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 -------------------------------------------- +marker_jar="${IVY_LOCAL}/dapr__dapr_sjs1_3/${EXPECTED_DAPR}/jars/dapr__dapr_sjs1_3.jar" +if [[ -f "${marker_jar}" ]]; then + echo "ScalablyTyped facades already present (${marker_jar}); 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})..." +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}") + +# --- 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/src/js/Dapr.scala b/src/js/Dapr.scala index a9dd82e..9308fc2 100644 --- a/src/js/Dapr.scala +++ b/src/js/Dapr.scala @@ -3,7 +3,7 @@ package dapr4s import scala.scalajs.js import scala.util.control.NonFatal -import dapr4s.internal.facade +import typings.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`). @@ -46,8 +46,8 @@ import dapr4s.internal.facade * 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 SDK clients it wraps are - * managed entirely here. + * operations. The internal use of `DaprCapabilityImpl` (a JS-SDK-backed class) and the ScalablyTyped-generated SDK + * clients it wraps (`typings.daprDapr` — see js-deps.scala) are managed entirely here. */ @scala.caps.assumeSafe class Dapr(config: DaprConfig = DaprConfig()): @@ -74,9 +74,9 @@ class Dapr(config: DaprConfig = DaprConfig()): */ def run[T](body: DaprCapability ?=> T): T = val sc = config.sidecar - val client = new facade.DaprClient(internal.DaprCapabilityImpl.httpClientOptions(sc)) - val grpcClientRef = new internal.LazyClientRef[facade.DaprClient] - val workflowClientRef = new internal.LazyClientRef[facade.DaprWorkflowClient] + 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) diff --git a/src/js/internal/ActorCapabilityImpl.scala b/src/js/internal/ActorCapabilityImpl.scala index 81eb811..ce5b7bc 100644 --- a/src/js/internal/ActorCapabilityImpl.scala +++ b/src/js/internal/ActorCapabilityImpl.scala @@ -3,6 +3,8 @@ package dapr4s.internal import dapr4s.* import scala.scalajs.js +import typings.node.globalsMod.global as NodeGlobals +import typings.undiciTypes.fetchMod.RequestInit /** Client-side capability for invoking methods on a specific Dapr virtual actor instance — the Scala.js twin of the JVM * `ActorCapabilityImpl`. @@ -15,10 +17,11 @@ import scala.scalajs.js * 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` + - * [[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). + * 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 @@ -81,22 +84,23 @@ private[internal] object ActorCapabilityImpl: /** 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.Dictionary[String] = - val headers = js.Dictionary("Content-Type" -> "application/json") - sidecar.apiToken.foreach(t => headers("dapr-api-token") = t.value) + 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 = new facade.FetchRequestInit( - method = "POST", - headers = baseHeaders(sidecar), - body = body.fold[js.UndefOr[String]](js.undefined)(b => b), - ) - val response = JsAwait.await(facade.NodeGlobals.fetch(url, init)) + 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 diff --git a/src/js/internal/ConfigurationCapabilityImpl.scala b/src/js/internal/ConfigurationCapabilityImpl.scala index d01dc03..0c75b33 100644 --- a/src/js/internal/ConfigurationCapabilityImpl.scala +++ b/src/js/internal/ConfigurationCapabilityImpl.scala @@ -6,6 +6,9 @@ import scala.scalajs.js import scala.scalajs.js.JSConverters.* import scala.util.control.NonFatal import JsInterop.* +import typings.daprDapr.typesConfigurationConfigurationItemMod.ConfigurationItem as SdkConfigurationItem +import typings.daprDapr.typesConfigurationSubscribeConfigurationCallbackMod.SubscribeConfigurationCallback +import typings.daprDapr.typesConfigurationSubscribeConfigurationResponseMod.SubscribeConfigurationResponse @scala.caps.assumeSafe private[internal] final class ConfigurationCapabilityImpl( @@ -36,8 +39,8 @@ private[internal] final class ConfigurationCapabilityImpl( // 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[facade.SubscribeConfigurationResponse, js.Promise[Unit]]^{this, onChange} = - (response: facade.SubscribeConfigurationResponse) => + 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)) @@ -49,9 +52,10 @@ private[internal] final class ConfigurationCapabilityImpl( 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 facade - // signature mirrors the SDK's TypeScript callback type, which is necessarily capture-free — a JS - // interop boundary cannot carry capture annotations. + // 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 @@ -62,22 +66,46 @@ private[internal] final class ConfigurationCapabilityImpl( storeNameStr, keys.map(_.value).toJSArray, toDict(metadata), - callback.asInstanceOf[js.Function1[facade.SubscribeConfigurationResponse, js.Promise[Unit]]], + callback.asInstanceOf[SubscribeConfigurationCallback], ), ) - // stop() is async (it aborts the stream and sends the explicit unsubscribe RPC); awaiting it - // makes close() synchronous like the JVM's `() => sub.dispose()`. - () => JsAwait.await(stream.stop()) + // 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: - private def toConfigItem(k: String, item: facade.ConfigurationItemJs): ConfigurationItem = + /** 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.getOrElse("")), - version = ConfigurationVersion(item.version.getOrElse("")), - metadata = item.metadata.toOption.fold(Map.empty[MetadataKey, MetadataValue]) { jm => - jm.iterator.map { case (mk, mv) => MetadataKey(mk) -> MetadataValue(mv) }.toMap - }, + 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 index e78cda9..eda0f46 100644 --- a/src/js/internal/CryptoCapabilityImpl.scala +++ b/src/js/internal/CryptoCapabilityImpl.scala @@ -3,7 +3,11 @@ package dapr4s.internal import dapr4s.* import scala.collection.immutable.ArraySeq +import scala.scalajs.js import scala.scalajs.js.typedarray.{Int8Array, Uint8Array} +import typings.daprDapr.typesCryptoRequestsMod.{DecryptRequest, EncryptRequest} +import typings.node.bufferMod.global.Buffer +import typings.std.ArrayBufferLike @scala.caps.assumeSafe private[internal] final class CryptoCapabilityImpl( @@ -20,23 +24,45 @@ private[internal] final class CryptoCapabilityImpl( // 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 = new facade.EncryptRequest( + val request = EncryptRequest( componentName = componentName.value, keyName = keyName.value, - keyWrapAlgorithm = algorithm.value, + keyWrapAlgorithm = toJsKeyWrapAlgorithm(algorithm), ) val result = JsAwait.await(scope.grpcClient.crypto.encrypt(toInt8Array(plaintext), request)) - fromUint8Array(result) + 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 = new facade.DecryptRequest(componentName = componentName.value) + val request = DecryptRequest(componentName = componentName.value) val result = JsAwait.await(scope.grpcClient.crypto.decrypt(toInt8Array(ciphertext), request)) - fromUint8Array(result) + fromBuffer(result) @scala.caps.assumeSafe private object CryptoCapabilityImpl: + import typings.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) @@ -46,13 +72,21 @@ private object CryptoCapabilityImpl: i += 1 typed - private def fromUint8Array(buffer: Uint8Array): ArraySeq[Byte] = + 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. - val out = new Array[Byte](buffer.length) + // + // WHAT: asInstanceOf viewing the ScalablyTyped Buffer as the Scala.js-native js.typedarray.Uint8Array. + // WHY: ST's Buffer extends typings.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 < buffer.length do - out(i) = buffer(i).toByte + 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 index 55a11fd..a38afcd 100644 --- a/src/js/internal/DaprAppServer.scala +++ b/src/js/internal/DaprAppServer.scala @@ -7,6 +7,14 @@ 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 typings.expressServeStaticCore.mod.{Express, Handler, ParamsDictionary, Request, Response} +import typings.node.globalsMod.global as NodeGlobals +import typings.node.httpMod.{IncomingMessage, Server, ServerResponse} +import typings.node.nodeColonnetMod.Socket +import typings.node.processMod.global.NodeJS.Signals +import typings.qs.mod.ParsedQs +import typings.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. @@ -18,10 +26,11 @@ import scala.util.control.NonFatal * * 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). 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. + * 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== * @@ -105,18 +114,18 @@ private[dapr4s] final class DaprAppServer(app: DaprApp): // the empty-404 fallback. // ----------------------------------------------------------------------- - val expressApp = facade.Express() - expressApp.use( - facade.Express.text(new facade.ExpressTextOptions(`type` = "*/*", limit = Double.MaxValue)), + 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.all( + expressApp.allRoute( "/dapr/subscribe", - (req, res) => + (req, res, _) => handleAsync(res, "/dapr/subscribe") { () => - if req.method == "GET" then + 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)) @@ -129,11 +138,11 @@ private[dapr4s] final class DaprAppServer(app: DaprApp): // Dapr sidecar calls GET /dapr/config to discover hosted actor types. // Served unconditionally (even with no actors), exactly like the JVM. - expressApp.all( + expressApp.allRoute( "/dapr/config", - (req, res) => + (req, res, _) => handleAsync(res, "/dapr/config") { () => - if req.method == "GET" then sendJson(res, 200, actorConfigJson(actorDefs, actorConfig)) + if req.method.contains("GET") then sendJson(res, 200, actorConfigJson(actorDefs, actorConfig)) else sendEmpty(res, 405) }, ) @@ -146,9 +155,9 @@ private[dapr4s] final class DaprAppServer(app: DaprApp): // 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.put( + expressApp.putRoute( "/actors/:actorType/:actorId/method/remind/:reminderName", - (req, res) => + (req, res, _) => handleAsync(res, req.path) { () => dispatchActorReminder( res, @@ -161,9 +170,9 @@ private[dapr4s] final class DaprAppServer(app: DaprApp): ) }, ) - expressApp.put( + expressApp.putRoute( "/actors/:actorType/:actorId/method/timer/:timerName", - (req, res) => + (req, res, _) => handleAsync(res, req.path) { () => dispatchActorTimer( res, @@ -176,9 +185,9 @@ private[dapr4s] final class DaprAppServer(app: DaprApp): ) }, ) - expressApp.put( + expressApp.putRoute( "/actors/:actorType/:actorId/method/:methodName", - (req, res) => + (req, res, _) => handleAsync(res, req.path) { () => val methodName = param(req, "methodName") // Mirror the JVM's pattern guard (`methodName != "remind" && methodName != "timer"`): @@ -197,10 +206,10 @@ private[dapr4s] final class DaprAppServer(app: DaprApp): ) }, ) - expressApp.delete( + expressApp.deleteRoute( "/actors/:actorType/:actorId", - (req, res) => - handleAsync(res, req.path) { () => + (_, res, _) => + handleAsync(res, "/actors") { () => // Actor deactivation — no cleanup needed in our model (same as the JVM). sendEmpty(res, 200) }, @@ -216,9 +225,9 @@ private[dapr4s] final class DaprAppServer(app: DaprApp): ) val fn: String => SubscriptionResult = bodyJson => parseCloudEvent(bodyJson, sub.codec, sub.pubsubName, sub.topic, handler) - expressApp.all( + expressApp.allRoute( path, - erased((req, res) => + 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. @@ -247,9 +256,9 @@ private[dapr4s] final class DaprAppServer(app: DaprApp): s"Cannot decode binding payload for '${bin.bindingName.value}': ${e.getMessage}", e, ) - expressApp.all( + expressApp.allRoute( path, - erased((req, res) => + erased((req, res, _) => handleAsync(res, path) { () => fn(readBody(req)) sendEmpty(res, 200) @@ -283,9 +292,13 @@ private[dapr4s] final class DaprAppServer(app: DaprApp): s"Cannot decode invocation request for '${inv.methodName.value}': ${e.getMessage}", e, ) - expressApp.all( + expressApp.allRoute( path, - erased((req, res) => handleAsync(res, path)(() => sendJson(res, 200, fn(req.method, readBody(req))))), + 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). @@ -300,9 +313,9 @@ private[dapr4s] final class DaprAppServer(app: DaprApp): s"Cannot decode job payload for '${job.name.value}': ${e.getMessage}", e, ) - expressApp.all( + expressApp.allRoute( path, - erased((req, res) => + erased((req, res, _) => handleAsync(res, path) { () => fn(readBody(req)) sendEmpty(res, 200) @@ -312,21 +325,34 @@ private[dapr4s] final class DaprAppServer(app: DaprApp): // Fallback for everything unrouted: the JVM catch-all's empty-bodied 404 // (replacing express's default HTML "Cannot GET ..." page). - expressApp.use((req, res, next) => sendEmpty(res, 404)) + expressApp.mount((_, res, _) => sendEmpty(res, 404)) // ----------------------------------------------------------------------- // Lifecycle // ----------------------------------------------------------------------- - val server = - if httpBacklog > 0 then expressApp.listen(port, httpBacklog, () => ()) - else expressApp.listen(port, () => ()) + 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 = - server.close(() => facade.NodeProcess.exit(0)) + 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 @@ -336,16 +362,16 @@ private[dapr4s] final class DaprAppServer(app: DaprApp): case NonFatal(e) => js.Dynamic.global.console.warn(s"dapr4s: workflow host close failed during shutdown: $e"): Unit } - js.timers.setTimeout(shutdownGrace)(facade.NodeProcess.exit(0)): Unit - facade.NodeProcess.on("SIGINT", () => shutdown()): Unit - facade.NodeProcess.on("SIGTERM", () => shutdown()): 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", + server.on_error( + typings.node.nodeStrings.error, (err: js.Error) => { reject(err) () @@ -373,17 +399,72 @@ private[dapr4s] final class DaprAppServer(app: DaprApp): @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[typings.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 (`ExpressHandler^` accepts any - * capturing handler; the cast forgets the set). + /** WHAT: `asInstanceOf` erasing an express handler lambda's inferred capture set (`Handler^` accepts any capturing + * handler; the cast forgets the set). * - * WHY: `js.Function2` is a Scala-defined SAM, so CC tracks the closure's captures — the route handlers for - * user-defined routes capture their dispatch closure (`fn`), which transitively reaches the enclosing - * `DaprAppServer`. The facade signature (`ExpressApp.all` etc.) mirrors express's JavaScript callback type, which is - * necessarily capture-free — a JS interop boundary cannot carry capture annotations. + * 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 @@ -391,8 +472,8 @@ private object DaprAppServer: * 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: facade.ExpressHandler^): facade.ExpressHandler = - handler.asInstanceOf[facade.ExpressHandler] + 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). @@ -403,7 +484,7 @@ private object DaprAppServer: * 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: facade.ExpressResponse, path: String)(dispatch: () => Unit): Unit = + private def handleAsync(res: ExpressResponse, path: String)(dispatch: () => Unit): Unit = val completion = js.async { try dispatch() catch @@ -422,7 +503,7 @@ private object DaprAppServer: // ------------------------------------------------------------------------- private def dispatchActorMethod( - res: facade.ExpressResponse, + res: ExpressResponse, actorType: String, actorId: String, methodName: String, @@ -444,7 +525,7 @@ private object DaprAppServer: case Right(req) => sendJson(res, 200, route.respCodec.encode(handler(req))) private def dispatchActorReminder( - res: facade.ExpressResponse, + res: ExpressResponse, actorType: String, actorId: String, reminderName: String, @@ -467,7 +548,7 @@ private object DaprAppServer: sendEmpty(res, 200) private def dispatchActorTimer( - res: facade.ExpressResponse, + res: ExpressResponse, actorType: String, actorId: String, timerName: String, @@ -502,23 +583,36 @@ private object DaprAppServer: case _ => HttpMethod.Post /** A named route parameter; defensively "" when absent (express always populates declared params). */ - private def param(req: facade.ExpressRequest, name: String): String = - req.params.getOrElse(name, "") + 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: facade.ExpressRequest): String = - (req.body: Any) match + private def readBody(req: ExpressRequest): String = + req.body match case s: String => s case _ => "" - private def sendJson(res: facade.ExpressResponse, code: Int, body: String): Unit = - res.status(code).`type`("application/json").send(body) + 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)`. */ - private def sendEmpty(res: facade.ExpressResponse, code: Int): Unit = - res.status(code).end() + /** 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 diff --git a/src/js/internal/DaprCapabilityImpl.scala b/src/js/internal/DaprCapabilityImpl.scala index b475bb6..bcd76d1 100644 --- a/src/js/internal/DaprCapabilityImpl.scala +++ b/src/js/internal/DaprCapabilityImpl.scala @@ -3,7 +3,8 @@ package dapr4s.internal import dapr4s.* import java.net.URI -import scala.scalajs.js +import typings.daprDapr.anon.{PartialDaprClientOptions, PartialWorkflowClientOpti} +import typings.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 @@ -33,23 +34,23 @@ private[dapr4s] final class LazyClientRef[A]: /** 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, the individual `*CapabilityImpl` classes, and the - * facades in `dapr4s.internal.facade`. No JS types are visible in the public API. + * All interaction with the JS SDK is confined to this file and the individual `*CapabilityImpl` classes, through the + * ScalablyTyped-generated `typings.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 [[facade.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 [[facade.DaprWorkflowClient]] (gRPC, vendored durabletask) are created on - * first use via [[LazyClientRef.getOrCreate]], so `run` can close only what was actually created. + * 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: facade.DaprClient, + private[internal] val client: DaprClient, private[internal] val sidecar: SidecarConfig, - private val grpcClientRef: LazyClientRef[facade.DaprClient], - private val workflowClientRef: LazyClientRef[facade.DaprWorkflowClient], + private val grpcClientRef: LazyClientRef[DaprClient], + private val workflowClientRef: LazyClientRef[DaprWorkflowClient], ) extends DaprCapability: import DaprCapabilityImpl.* @@ -57,8 +58,8 @@ private[dapr4s] final class 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: facade.DaprClient = - grpcClientRef.getOrCreate(() => new facade.DaprClient(grpcClientOptions(sidecar))) + 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`. @@ -90,7 +91,7 @@ private[dapr4s] final class DaprCapabilityImpl( new ActorCapabilityImpl(actorType, actorId, sidecar).asInstanceOf[ActorCapability] def workflow: WorkflowCapability^{this} = - val wc = workflowClientRef.getOrCreate(() => new facade.DaprWorkflowClient(workflowClientOptions(sidecar))) + val wc = workflowClientRef.getOrCreate(() => new DaprWorkflowClient(workflowClientOptions(sidecar))) new WorkflowCapabilityImpl(wc).asInstanceOf[WorkflowCapability] def crypto(componentName: CryptoComponentName): CryptoCapability^{this} = @@ -145,9 +146,6 @@ private[dapr4s] object DaprCapabilityImpl: val hostPart = if scheme.isEmpty then host else s"$scheme://$host" (hostPart, port.toString) - private def undefOr[A](o: Option[A]): js.UndefOr[A] = - o.fold[js.UndefOr[A]](js.undefined)(a => a) - /** 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 @@ -155,32 +153,30 @@ private[dapr4s] object DaprCapabilityImpl: * 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): facade.DaprClientOptions = + private[dapr4s] def httpClientOptions(sc: SidecarConfig): PartialDaprClientOptions = val (host, port) = hostAndPort(sc.httpEndpoint, forGrpc = false) - new facade.DaprClientOptions( - daprHost = host, - daprPort = port, - communicationProtocol = facade.CommunicationProtocolEnum.HTTP, - daprApiToken = undefOr(sc.apiToken.map(_.value)), - maxBodySizeMb = sc.grpcMaxInboundMessageSizeBytes.toDouble / (1024 * 1024), - ) + 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): facade.DaprClientOptions = + private[internal] def grpcClientOptions(sc: SidecarConfig): PartialDaprClientOptions = val (host, port) = hostAndPort(sc.grpcEndpoint, forGrpc = true) - new facade.DaprClientOptions( - daprHost = host, - daprPort = port, - communicationProtocol = facade.CommunicationProtocolEnum.GRPC, - daprApiToken = undefOr(sc.apiToken.map(_.value)), - maxBodySizeMb = sc.grpcMaxInboundMessageSizeBytes.toDouble / (1024 * 1024), - ) + 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): facade.WorkflowClientOptions = + private[internal] def workflowClientOptions(sc: SidecarConfig): PartialWorkflowClientOpti = val (host, port) = hostAndPort(sc.grpcEndpoint, forGrpc = true) - new facade.WorkflowClientOptions( - daprHost = host, - daprPort = port, - daprApiToken = undefOr(sc.apiToken.map(_.value)), - ) + 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 index ca336c6..1f8271e 100644 --- a/src/js/internal/HttpActorContext.scala +++ b/src/js/internal/HttpActorContext.scala @@ -4,10 +4,12 @@ package dapr4s.internal import dapr4s.* import scala.concurrent.duration.FiniteDuration import scala.scalajs.js +import typings.node.globalsMod.global as NodeGlobals +import typings.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` + [[JsAwait]] instead of - * `HttpURLConnection`. + * 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. @@ -58,8 +60,8 @@ private[internal] final class HttpActorContext( def get[T: JsonCodec](key: ActorStateKey): Option[T] = val url = stateUrl(key) - val init = new facade.FetchRequestInit(method = "GET", headers = ActorCapabilityImpl.baseHeaders(sidecar)) - val response = JsAwait.await(facade.NodeGlobals.fetch(url, init)) + 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. @@ -135,12 +137,8 @@ private object HttpActorContext: * until the body is read). */ private def postJson(sidecar: SidecarConfig, url: String, body: String): Unit = - val init = new facade.FetchRequestInit( - method = "POST", - headers = ActorCapabilityImpl.baseHeaders(sidecar), - body = body, - ) - val response = JsAwait.await(facade.NodeGlobals.fetch(url, init)) + 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") @@ -148,6 +146,6 @@ private object HttpActorContext: * unregistering a missing reminder/timer is a documented no-op. */ private def deleteRequest(sidecar: SidecarConfig, url: String): Unit = - val init = new facade.FetchRequestInit(method = "DELETE", headers = ActorCapabilityImpl.baseHeaders(sidecar)) - val response = JsAwait.await(facade.NodeGlobals.fetch(url, init)) + 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 index cc96e20..56476a4 100644 --- a/src/js/internal/InvokeCapabilityImpl.scala +++ b/src/js/internal/InvokeCapabilityImpl.scala @@ -4,6 +4,16 @@ 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 typings.daprDapr.enumHttpMethodDotenumMod.HttpMethod as SdkHttpMethod +import typings.daprDapr.mod.HttpMethod as SdkHttpMethods +import typings.daprDapr.typesInvokerOptionsDottypeMod.InvokerOptions +import typings.node.globalsMod.global as NodeGlobals +import typings.undiciTypes.fetchMod.RequestInit @scala.caps.assumeSafe private object InvokeCapabilityImpl: @@ -36,31 +46,52 @@ private object InvokeCapabilityImpl: // baseHeaders supplies Content-Type: application/json + dapr-api-token; metadata adds headers // on top, mirroring the SDK path's InvokerOptions.headers. val headers = ActorCapabilityImpl.baseHeaders(sidecar) - metadata.foreach { case (k, v) => headers(k.value) = v.value } + 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 = new facade.FetchRequestInit(method = toJsMethod(httpMethod).toUpperCase, headers = headers, body = json) - val response = JsAwait.await(facade.NodeGlobals.fetch(url, init)) + 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`). + /** 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. * - * The SDK enum only declares get/delete/post/put/patch; `"head"` and `"options"` are still correct because the value - * flows verbatim into `HTTPClient.execute`, which upper-cases it and hands it to fetch (`clientOptions.method = - * params?.method.toLocaleUpperCase()`). + * 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): String = + private def toJsMethod(m: HttpMethod): SdkHttpMethod & String = m match - case HttpMethod.Get => "get" - case HttpMethod.Post => "post" - case HttpMethod.Put => "put" - case HttpMethod.Delete => "delete" - case HttpMethod.Patch => "patch" - case HttpMethod.Head => "head" - case HttpMethod.Options => "options" + 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( @@ -88,15 +119,15 @@ private[internal] final class InvokeCapabilityImpl( // 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(metadata) + val headers = toDict[Any](metadata) headers("Content-Type") = "application/json" val response = JsAwait.await( scope.client.invoker.invoke( appId.value, method.value, toJsMethod(httpMethod), - parsed, - new facade.InvokerOptions(headers = headers), + asInvokeData(parsed), + InvokerOptions().setHeaders(headers), ), ) // An empty response body surfaces as "" (HTTPClient.execute's tryParseJson); jsonStringOrNull @@ -104,7 +135,5 @@ private[internal] final class InvokeCapabilityImpl( 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, "get", js.undefined, new facade.InvokerOptions()), - ) + 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/JsInterop.scala b/src/js/internal/JsInterop.scala index 8b475f2..69178d7 100644 --- a/src/js/internal/JsInterop.scala +++ b/src/js/internal/JsInterop.scala @@ -4,6 +4,8 @@ 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. * @@ -18,6 +20,20 @@ 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 `typings.*` 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.) @@ -53,18 +69,24 @@ private[internal] object JsInterop: /** 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 `""`). + * 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: js.Any): Boolean = - js.isUndefined(v) || (v == null) || ((v: Any) match + def isAbsent(v: Any): Boolean = + js.isUndefined(v) || (v == null) || (v match case s: String => s.isEmpty case _ => false) - /** dapr4s metadata map → the `KeyValueType` string dictionary the SDK options take. */ - def toDict(metadata: Map[MetadataKey, MetadataValue]): js.Dictionary[String] = - val d = js.Dictionary.empty[String] - metadata.foreach { case (k, v) => d(k.value) = v.value } - d + /** 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. * diff --git a/src/js/internal/LockCapabilityImpl.scala b/src/js/internal/LockCapabilityImpl.scala index 6930042..cde6b62 100644 --- a/src/js/internal/LockCapabilityImpl.scala +++ b/src/js/internal/LockCapabilityImpl.scala @@ -14,10 +14,12 @@ private[internal] final class LockCapabilityImpl( // 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.toInt), + scope.client.lock.lock(storeName.value, resourceId.value, lockOwner.value, expiry.toSeconds.toDouble), ) - // Absent `success` → false, mirroring the JVM's `.toOption.exists(_.booleanValue())` null handling. - response.success.contains(true) + // 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)) @@ -26,8 +28,16 @@ private[internal] final class LockCapabilityImpl( // 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. - response.status.toOption.fold(UnlockStatus.InternalError) { - case 0 => UnlockStatus.Success - case 1 => UnlockStatus.LockNotFound - case _ => UnlockStatus.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 index df7cdda..89d0bae 100644 --- a/src/js/internal/PublishCapabilityImpl.scala +++ b/src/js/internal/PublishCapabilityImpl.scala @@ -5,6 +5,14 @@ import dapr4s.* import scala.scalajs.js import scala.scalajs.js.JSConverters.* import JsInterop.* +import typings.daprDapr.typesPubsubPubSubBulkPublishMessageDottypeMod.{ + PubSubBulkPublishMessage, + PubSubBulkPublishMessageExplicit, +} +import typings.daprDapr.typesPubsubPubSubPublishOptionsDottypeMod.PubSubPublishOptions +import typings.daprDapr.typesPubsubPubSubPublishResponseDottypeMod.PubSubPublishResponseType +import typings.node.globalsMod.global as NodeGlobals +import typings.undiciTypes.fetchMod.RequestInit @scala.caps.assumeSafe private[internal] final class PublishCapabilityImpl( @@ -34,7 +42,7 @@ private[internal] final class PublishCapabilityImpl( 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, parsed, jsonContentTypeOptions), + scope.client.pubsub.publish(pubsubName.value, topic.value, asPublishData(parsed), jsonContentTypeOptions), ) throwIfFailed(response) @@ -48,21 +56,27 @@ private[internal] final class PublishCapabilityImpl( // Falsy payloads bypass the SDK — see the publish comment above. if isFalsyJson(parsed) then rawPublish(scope.sidecar, pubsubName, topic, json, metadata) else - val options = new facade.PubSubPublishOptions(contentType = "application/json", metadata = toDict(metadata)) - val response = JsAwait.await(scope.client.pubsub.publish(pubsubName.value, topic.value, parsed, options)) + 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) and - // pins application/json per entry for the same scalar-payload reason as publish above. - val messages = entries.map { entry => - new facade.PubSubBulkPublishMessage( - entryID = entry.entryId.value, - event = parseJson(summon[JsonCodec[T]].encode(entry.event)), - contentType = "application/json", - ) - }.toJSArray + // (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), @@ -85,12 +99,27 @@ private[internal] final class PublishCapabilityImpl( @scala.caps.assumeSafe private object PublishCapabilityImpl: - private val jsonContentTypeOptions = new facade.PubSubPublishOptions(contentType = "application/json") + 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`); - * rethrow to mirror the JVM impl, where a failed `publishEvent` throws `DaprException`. + /** `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: facade.SoftFailureResponse): Unit = + 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. @@ -119,12 +148,8 @@ private object PublishCapabilityImpl: .mkString("?", "&", "") val base = ActorCapabilityImpl.httpBase(sidecar) val url = s"$base/v1.0/publish/${urlSegment(pubsubName.value)}/${urlSegment(topic.value)}$query" - val init = new facade.FetchRequestInit( - method = "POST", - headers = ActorCapabilityImpl.baseHeaders(sidecar), - body = json, - ) - val response = JsAwait.await(facade.NodeGlobals.fetch(url, init)) + 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/StateCapabilityImpl.scala b/src/js/internal/StateCapabilityImpl.scala index 2b36e97..b215a09 100644 --- a/src/js/internal/StateCapabilityImpl.scala +++ b/src/js/internal/StateCapabilityImpl.scala @@ -5,26 +5,47 @@ import dapr4s.* import scala.scalajs.js import scala.scalajs.js.JSConverters.* import JsInterop.* +import typings.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 typings.daprDapr.enumStateConcurrencyDotenumMod.StateConcurrencyEnum +import typings.daprDapr.enumStateConsistencyDotenumMod.StateConsistencyEnum +import typings.daprDapr.mod.{StateConcurrencyEnum as SdkConcurrency, StateConsistencyEnum as SdkConsistency} +import typings.daprDapr.typesKeyValuePairDottypeMod.KeyValuePairType +import typings.daprDapr.typesOperationDottypeMod.OperationType +import typings.daprDapr.typesRequestDottypeMod.IRequest +import typings.daprDapr.typesStateStateOptionsDottypeMod.IStateOptions +import typings.daprDapr.typesStateStateQueryDottypeMod.StateQueryType +import typings.daprDapr.typesStateStateSaveOptionsDottypeMod.StateSaveOptions +import typings.daprDapr.typesStateStateSaveResponseTypeMod.StateSaveResponseType +import typings.node.globalsMod.global as NodeGlobals +import typings.undiciTypes.fetchMod.RequestInit @scala.caps.assumeSafe private object StateCapabilityImpl: - /** dapr4s enum → numeric `StateConsistencyEnum` (CONSISTENCY_EVENTUAL = 1, CONSISTENCY_STRONG = 2); `Default` maps to - * `undefined`, which `getStateConsistencyValue` turns into "no query parameter" — the same store-default behaviour - * the JVM impl gets from passing a `null` Java enum. + /** 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): js.UndefOr[Int] = + private def toJsConsistency(c: StateConsistency): StateConsistencyEnum = c match - case StateConsistency.Default => js.undefined - case StateConsistency.Eventual => 1 - case StateConsistency.Strong => 2 + 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). */ - private def toJsConcurrency(c: StateConcurrency): js.UndefOr[Int] = + /** 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 => js.undefined - case StateConcurrency.FirstWrite => 1 - case StateConcurrency.LastWrite => 2 + 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] = @@ -36,25 +57,31 @@ private object StateCapabilityImpl: private def decode[T: JsonCodec](raw: String | Null): T = JsonCodec.decodeOrThrow[T](raw) - private def toJsOp(op: StateOp): facade.StateTransactionOperation = + /** 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): typings.daprDapr.typesEtagDottypeMod.IEtag = + etag.value.asInstanceOf[typings.daprDapr.typesEtagDottypeMod.IEtag] + + private def toJsOp(op: StateOp): OperationType = op match case StateOp.UpsertOp(key, encodedValue, etag) => - new facade.StateTransactionOperation( - operation = "upsert", - request = new facade.StateTransactionRequest( - key = key.value, - value = parseJson(encodedValue.value), - etag = etag.fold[js.UndefOr[String]](js.undefined)(_.value), - ), - ) + 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) => - new facade.StateTransactionOperation( - operation = "delete", - request = new facade.StateTransactionRequest( - key = key.value, - etag = etag.fold[js.UndefOr[String]](js.undefined)(_.value), - ), - ) + 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 @@ -68,12 +95,12 @@ private object StateCapabilityImpl: /** 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`); 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. + * (`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: facade.SoftFailureResponse, + response: StateSaveResponseType, key: StateStoreKey, etag: ETag, ): Option[ETagMismatchException] = @@ -98,12 +125,14 @@ private[internal] final class StateCapabilityImpl( scope.client.state.get( storeName.value, key.value, - new facade.StateGetOptions(consistency = toJsConsistency(consistency)), + 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(raw))) + if isAbsent(raw) then None else Some(decode[T](js.JSON.stringify(asJsAny(raw)))) def getWithETag[T: JsonCodec]( key: StateStoreKey, @@ -119,10 +148,7 @@ private[internal] final class StateCapabilityImpl( val base = ActorCapabilityImpl.httpBase(scope.sidecar) val url = s"$base/v1.0/state/${urlSegment(storeName.value)}/${urlSegment(key.value)}$query" val response = JsAwait.await( - facade.NodeGlobals.fetch( - url, - new facade.FetchRequestInit(method = "GET", headers = ActorCapabilityImpl.baseHeaders(scope.sidecar)), - ), + 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) @@ -137,16 +163,26 @@ private[internal] final class StateCapabilityImpl( 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 raw = item.data.toOption.filterNot(isAbsent) - val etag = item.etag.toOption.map(ETag(_)) - StateStoreKey(item.key) -> StateEntry(raw.map(d => decode[T](js.JSON.stringify(d))), etag) + 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 = new facade.StateKeyValuePair(key = key.value, value = parseJson(json)) + 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)) @@ -154,7 +190,7 @@ private[internal] final class StateCapabilityImpl( def saveBulk[T: JsonCodec](entries: Seq[(StateStoreKey, T)]): Unit = if entries.nonEmpty then val jsEntries = entries.map { case (key, value) => - new facade.StateKeyValuePair(key = key.value, value = parseJson(summon[JsonCodec[T]].encode(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)) @@ -168,22 +204,20 @@ private[internal] final class StateCapabilityImpl( concurrency: StateConcurrency = StateConcurrency.FirstWrite, ): Option[ETagMismatchException] = val json = summon[JsonCodec[T]].encode(value) - val entry = new facade.StateKeyValuePair( - key = key.value, - value = parseJson(json), - etag = etag.value, - options = new facade.StateOperationOptions( - consistency = toJsConsistency(consistency), - concurrency = toJsConcurrency(concurrency), - ), - ) - val options = new facade.StateSaveOptions(metadata = toDict(metadata)) + 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, new facade.StateDeleteOptions())) + val response = JsAwait.await(scope.client.state.delete(storeName.value, key.value)) response.error.toOption.foreach(e => throw js.JavaScriptException(e)) def deleteWithETag( @@ -192,11 +226,10 @@ private[internal] final class StateCapabilityImpl( consistency: StateConsistency = StateConsistency.Default, concurrency: StateConcurrency = StateConcurrency.FirstWrite, ): Option[ETagMismatchException] = - val options = new facade.StateDeleteOptions( - etag = etag.value, - consistency = toJsConsistency(consistency), - concurrency = toJsConcurrency(concurrency), - ) + 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) @@ -204,11 +237,19 @@ private[internal] final class StateCapabilityImpl( JsAwait.await(scope.client.state.transaction(storeName.value, ops.map(toJsOp).toJSArray)): Unit def queryState[T: JsonCodec](query: StateQuery): List[StateEntry[T]] = - val response = JsAwait.await(scope.client.state.query(storeName.value, parseJson(query.value))) - response.results.toOption.fold(List.empty[StateEntry[T]]) { items => - items.toList.map { item => - val raw = item.data.toOption.filterNot(isAbsent) - val etag = item.etag.toOption.map(ETag(_)) - StateEntry(raw.map(d => decode[T](js.JSON.stringify(d))), etag) - } + // 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 and always present at runtime: the SDK substitutes `{results: []}` for + // an empty response body (implementation/Client/HTTPClient/state.js `query`). + response.results.toList.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 index e80cd67..5fbeddf 100644 --- a/src/js/internal/WorkflowCapabilityImpl.scala +++ b/src/js/internal/WorkflowCapabilityImpl.scala @@ -5,26 +5,34 @@ 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 typings.daprDapr.mod.{DaprWorkflowClient, WorkflowRuntimeStatus as SdkStatuses} +import typings.daprDapr.workflowClientWorkflowStateMod.WorkflowState +import typings.daprDapr.workflowRuntimeWorkflowRuntimeStatusMod.WorkflowRuntimeStatus @scala.caps.assumeSafe private[internal] final class WorkflowCapabilityImpl( - private val client: facade.DaprWorkflowClient, + private val client: DaprWorkflowClient, ) extends WorkflowCapability: import WorkflowCapabilityImpl.* def start(name: WorkflowName): WorkflowInstanceId = - WorkflowInstanceId(JsAwait.await(client.scheduleNewWorkflow(name.value, js.undefined, js.undefined))) + 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, js.undefined))) + WorkflowInstanceId(JsAwait.await(client.scheduleNewWorkflow(name.value, value))) def startWithId(name: WorkflowName, instanceId: WorkflowInstanceId): WorkflowInstanceId = - WorkflowInstanceId(JsAwait.await(client.scheduleNewWorkflow(name.value, js.undefined, instanceId.value))) + // 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)) @@ -95,7 +103,7 @@ private object WorkflowCapabilityImpl: error.message == "TimeoutError" || error.asInstanceOf[js.Dynamic].selectDynamic("constructor").selectDynamic("name").toString == "TimeoutError" - private def toSnapshot(state: facade.WorkflowState): WorkflowSnapshot = + private def toSnapshot(state: WorkflowState): WorkflowSnapshot = WorkflowSnapshot( name = WorkflowName(state.name), instanceId = WorkflowInstanceId(state.instanceId), @@ -109,15 +117,15 @@ private object WorkflowCapabilityImpl: /** 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. + * 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: Int): WorkflowStatus = - rs match - case facade.WorkflowRuntimeStatus.RUNNING => WorkflowStatus.Running - case facade.WorkflowRuntimeStatus.COMPLETED => WorkflowStatus.Completed - case facade.WorkflowRuntimeStatus.CONTINUED_AS_NEW => WorkflowStatus.ContinuedAsNew - case facade.WorkflowRuntimeStatus.FAILED => WorkflowStatus.Failed - case facade.WorkflowRuntimeStatus.TERMINATED => WorkflowStatus.Terminated - case facade.WorkflowRuntimeStatus.PENDING => WorkflowStatus.Pending - case facade.WorkflowRuntimeStatus.SUSPENDED => WorkflowStatus.Suspended - case _ => WorkflowStatus.Pending + 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 index 58d31ac..e573f1f 100644 --- a/src/js/internal/WorkflowContextImpl.scala +++ b/src/js/internal/WorkflowContextImpl.scala @@ -5,10 +5,18 @@ import dapr4s.* import scala.concurrent.duration.FiniteDuration import scala.scalajs.js import unsafeExceptions.canThrowAny +import typings.daprDapr.workflowInternalDurabletaskTaskTaskMod.Task as SdkTask +import typings.daprDapr.workflowRuntimeWorkflowContextMod.WorkflowContext as SdkWorkflowContext +import typings.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 @@ -18,12 +26,12 @@ import unsafeExceptions.canThrowAny * - 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 (see the - * [[facade.SdkTask]] doc), whereas the JVM SDK cancels e.g. timed-out external-event tasks. + * - [[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: facade.SdkTask, + 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 @@ -55,7 +63,9 @@ private[internal] final class TaskMap[O1, +O]( /** 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. + * 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)== * @@ -72,7 +82,7 @@ private[internal] final class TaskMap[O1, +O]( */ @scala.caps.assumeSafe private[internal] final class WorkflowContextImpl( - private val ctx: facade.SdkWorkflowContext, + private val ctx: SdkWorkflowContext, private val coroutine: WorkflowCoroutine, private val input: js.Any, ) extends WorkflowContext: @@ -119,7 +129,7 @@ private[internal] final class WorkflowContextImpl( /** 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: facade.SdkTask, decode: js.Any => O): TaskImpl[O] = + 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} = @@ -149,7 +159,7 @@ private[internal] final class WorkflowContextImpl( throw new java.util.concurrent.TimeoutException( s"external event '$eventName' was not raised within $timeout", ) - else decodeEvent(eventTask.getResult()), + else decodeEvent(JsInterop.asJsAny(eventTask.getResult())), ) def waitForExternalEvent[T: JsonCodec](name: EventName): Task[T]^{this} = @@ -217,12 +227,15 @@ private[internal] object WorkflowContextImpl: /** 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`, 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`. + /** 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 = facade.NodeCrypto.createHash("sha1").update(s"$UuidNamespace-$name", "utf8").digest("hex") + 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 diff --git a/src/js/internal/WorkflowCoroutine.scala b/src/js/internal/WorkflowCoroutine.scala index fb1abc2..a9d6ceb 100644 --- a/src/js/internal/WorkflowCoroutine.scala +++ b/src/js/internal/WorkflowCoroutine.scala @@ -4,6 +4,8 @@ package dapr4s.internal import dapr4s.* import scala.scalajs.js import scala.scalajs.js.annotation.JSName +import typings.daprDapr.workflowInternalDurabletaskTaskTaskMod.Task as SdkTask +import typings.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 @@ -103,7 +105,7 @@ private[internal] final class ContinueAsNewSignal extends scala.util.control.Con @scala.caps.assumeSafe private[internal] final class WorkflowCoroutine( private val workflow: Workflow, - private val sdkCtx: facade.SdkWorkflowContext, + private val sdkCtx: SdkWorkflowContext, private val input: js.Any, ) extends js.Object: @@ -148,7 +150,7 @@ private[internal] final class WorkflowCoroutine( * 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: facade.SdkTask): js.Any = + 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); () } diff --git a/src/js/internal/WorkflowHost.scala b/src/js/internal/WorkflowHost.scala index 880373c..cf887d8 100644 --- a/src/js/internal/WorkflowHost.scala +++ b/src/js/internal/WorkflowHost.scala @@ -3,9 +3,37 @@ package dapr4s.internal import dapr4s.* import scala.scalajs.js +import typings.daprDapr.mod.WorkflowRuntime +import typings.daprDapr.typesWorkflowActivityDottypeMod.TWorkflowActivity +import typings.daprDapr.typesWorkflowInputOutputDottypeMod.{TInput, TOutput} +import typings.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 [[facade.WorkflowRuntime]] and the [[WorkflowCoroutine]] bridge. + * `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 @@ -33,8 +61,8 @@ private[internal] object WorkflowHost: * * `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 [[facade.WorkflowRuntime]] doc), so this does not block on - * sidecar availability, same as the JVM's non-blocking `start(false)`. + * 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) @@ -54,22 +82,22 @@ private[internal] object WorkflowHost: daprCapability: DaprCapability, sidecar: SidecarConfig, ): Handle = - val runtime = new facade.WorkflowRuntime(DaprCapabilityImpl.workflowClientOptions(sidecar)) + 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 facade's js.Function2. - val fn: js.Function2[facade.SdkWorkflowContext, js.Any, js.Any] = - (sdkCtx, input) => new WorkflowCoroutine(w, sdkCtx, input) + // 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 type cannot carry capture annotations) is rejected by capture checking. + // 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 @@ -84,8 +112,8 @@ private[internal] object WorkflowHost: // 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: js.Function2[facade.SdkWorkflowActivityContext, js.Any, js.Any] = - (_, input) => js.async(runActivity(a, daprRef, input)) + val fn: TWorkflowActivity[TInput, TOutput] = + (_, input) => js.async(runActivity(a, daprRef, JsInterop.asJsAny(input))) runtime.registerActivityWithName(a.activityName, fn): Unit } @@ -95,7 +123,7 @@ private[internal] object WorkflowHost: 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 facade doc) and close() runs in a signal-listener JS frame where suspension is impossible. The JVM + // 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 diff --git a/src/js/internal/facade/DaprSdk.scala b/src/js/internal/facade/DaprSdk.scala deleted file mode 100644 index 8c1a6aa..0000000 --- a/src/js/internal/facade/DaprSdk.scala +++ /dev/null @@ -1,344 +0,0 @@ -//> using target.platform "scala-js" -package dapr4s.internal.facade - -import scala.scalajs.js -import scala.scalajs.js.annotation.JSImport - -// --------------------------------------------------------------------------- -// Scala.js facades for the `@dapr/dapr` npm package (the Dapr JS SDK, 3.x). -// -// Only the classes/enums re-exported from the package root (`index.js`) carry a -// @JSImport. The sub-client interfaces (`IClientState`, `IClientPubSub`, ...) -// and all `*.type.ts` shapes are TypeScript-only types, erased at runtime — -// they are modelled as structural `@js.native` traits WITHOUT @JSImport -// (request/option shapes we construct ourselves are non-native `js.Object` -// classes, so the fields become plain JS properties). -// -// Signatures were verified against the installed sources in -// node_modules/@dapr/dapr (v3.18.0): implementation/Client/DaprClient.d.ts, -// implementation/Client/HTTPClient/*.js, implementation/Client/GRPCClient/*.js, -// interfaces/Client/*.d.ts, types/**. Gotchas baked in below: -// - `CommunicationProtocolEnum` is numeric with GRPC = 0, HTTP = 1. -// - Ports are STRINGS everywhere (`daprPort: string`). -// - `HttpMethod` values are lowercase strings ("get", "post", ...). -// - Options objects are `Partial<...>` — every field is optional. -// --------------------------------------------------------------------------- - -/** Facade for the root `DaprClient` class (`implementation/Client/DaprClient.ts`). - * - * Only the sub-clients dapr4s needs are declared. `start()` is declared but does not need to be called eagerly: every - * sub-client call goes through `HTTPClient.execute` / `GRPCClient.getClient`, which auto-start the client (awaiting - * sidecar health) on first use. - */ -@js.native -@JSImport("@dapr/dapr", "DaprClient") -private[dapr4s] class DaprClient(options: DaprClientOptions) extends js.Object: - val state: StateClient = js.native - val pubsub: PubSubClient = js.native - val binding: BindingClient = js.native - val invoker: InvokerClient = js.native - val secret: SecretClient = js.native - val configuration: ConfigurationClient = js.native - val lock: LockClient = js.native - val crypto: CryptoClient = js.native - val health: HealthClient = js.native - def start(): js.Promise[Unit] = js.native - def stop(): js.Promise[Unit] = js.native - -/** Facade for `types/DaprClientOptions.ts`. All fields are optional (`Partial` in the SDK ctor). */ -private[dapr4s] final class DaprClientOptions( - val daprHost: js.UndefOr[String] = js.undefined, - val daprPort: js.UndefOr[String] = js.undefined, - val communicationProtocol: js.UndefOr[Int] = js.undefined, - val daprApiToken: js.UndefOr[String] = js.undefined, - val maxBodySizeMb: js.UndefOr[Double] = js.undefined, -) extends js.Object - -/** Facade for `enum/CommunicationProtocol.enum.ts`. Numeric, and `GRPC = 0`, `HTTP = 1` — reading the values off the - * real SDK enum (rather than hardcoding integers) keeps us correct if upstream ever renumbers. - */ -@js.native -@JSImport("@dapr/dapr", "CommunicationProtocolEnum") -private[dapr4s] object CommunicationProtocolEnum extends js.Object: - val GRPC: Int = js.native - val HTTP: Int = js.native - -// --------------------------------------------------------------------------- -// state (interfaces/Client/IClientState.ts, implementation/Client/HTTPClient/state.js) -// --------------------------------------------------------------------------- - -@js.native -private[internal] trait StateClient extends js.Object: - def save(storeName: String, stateObjects: js.Array[StateKeyValuePair]): js.Promise[SoftFailureResponse] = js.native - def save( - storeName: String, - stateObjects: js.Array[StateKeyValuePair], - options: StateSaveOptions, - ): js.Promise[SoftFailureResponse] = js.native - def get(storeName: String, key: String): js.Promise[js.Any] = js.native - def get(storeName: String, key: String, options: StateGetOptions): js.Promise[js.Any] = js.native - def getBulk(storeName: String, keys: js.Array[String]): js.Promise[js.Array[BulkStateItem]] = js.native - def delete(storeName: String, key: String, options: StateDeleteOptions): js.Promise[SoftFailureResponse] = js.native - def transaction(storeName: String, operations: js.Array[StateTransactionOperation]): js.Promise[Unit] = js.native - def query(storeName: String, query: js.Any): js.Promise[StateQueryResponse] = js.native - -/** `KeyValuePairType` (`types/KeyValuePair.type.ts`): one entry of a `state.save` call. */ -private[internal] final class StateKeyValuePair( - val key: String, - val value: js.Any, - val etag: js.UndefOr[String] = js.undefined, - val options: js.UndefOr[StateOperationOptions] = js.undefined, -) extends js.Object - -/** `IStateOptions`: per-entry write behaviour. The SDK maps these numeric enums to the `"eventual"`/`"strong"` and - * `"first-write"`/`"last-write"` strings of the HTTP API via `getStateConsistencyValue`/`getStateConcurrencyValue` - * (`utils/Client.util.js`); unspecified (`undefined`/0) maps to no query parameter at all. - */ -private[internal] final class StateOperationOptions( - val consistency: js.UndefOr[Int] = js.undefined, - val concurrency: js.UndefOr[Int] = js.undefined, -) extends js.Object - -/** `StateSaveOptions` (`types/state/StateSaveOptions.type.ts`): metadata becomes `metadata.*` query parameters. */ -private[internal] final class StateSaveOptions( - val metadata: js.UndefOr[js.Dictionary[String]] = js.undefined, -) extends js.Object - -/** `Partial` (`types/state/StateGetOptions.type.ts`). */ -private[internal] final class StateGetOptions( - val consistency: js.UndefOr[Int] = js.undefined, - val metadata: js.UndefOr[js.Dictionary[String]] = js.undefined, -) extends js.Object - -/** `Partial` (`types/state/StateDeleteOptions.type.ts`): `etag` becomes an `If-Match` header. */ -private[internal] final class StateDeleteOptions( - val etag: js.UndefOr[String] = js.undefined, - val consistency: js.UndefOr[Int] = js.undefined, - val concurrency: js.UndefOr[Int] = js.undefined, - val metadata: js.UndefOr[js.Dictionary[String]] = js.undefined, -) extends js.Object - -/** `OperationType` (`types/Operation.type.ts`): one entry of a `state.transaction` call. */ -private[internal] final class StateTransactionOperation( - val operation: String, - val request: StateTransactionRequest, -) extends js.Object - -/** `IRequest` (`types/Request.type.ts`): the key/value/etag payload of a transaction operation. */ -private[internal] final class StateTransactionRequest( - val key: String, - val value: js.UndefOr[js.Any] = js.undefined, - val etag: js.UndefOr[String] = js.undefined, -) extends js.Object - -/** One item of the raw sidecar response to `POST /v1.0/state/{store}/bulk` (and of `query().results`): the SDK passes - * the parsed JSON `[{key, data, etag}]` through verbatim. `data` is absent for missing keys. - */ -@js.native -private[internal] trait BulkStateItem extends js.Object: - def key: String = js.native - def data: js.UndefOr[js.Any] = js.native - def etag: js.UndefOr[String] = js.native - -/** `StateQueryResponseType`: `{results: [{key, data, etag}]}`; the SDK substitutes `{results: []}` for an empty body - * (`implementation/Client/HTTPClient/state.js` `query`). - */ -@js.native -private[internal] trait StateQueryResponse extends js.Object: - def results: js.UndefOr[js.Array[BulkStateItem]] = js.native - -/** Soft-failure response shape shared by `state.save`, `state.delete` (`StateSaveResponseType`) and `pubsub.publish` - * (`PubSubPublishResponseType`): the SDK catches the rejected `Error` and returns it as `{error}` instead of - * rethrowing (`implementation/Client/HTTPClient/{state,pubsub}.js`). - */ -@js.native -private[internal] trait SoftFailureResponse extends js.Object: - def error: js.UndefOr[js.Error] = js.native - -// --------------------------------------------------------------------------- -// pubsub (interfaces/Client/IClientPubSub.ts) -// --------------------------------------------------------------------------- - -@js.native -private[internal] trait PubSubClient extends js.Object: - def publish( - pubSubName: String, - topic: String, - data: js.Any, - options: PubSubPublishOptions, - ): js.Promise[SoftFailureResponse] = js.native - def publishBulk( - pubSubName: String, - topic: String, - messages: js.Array[PubSubBulkPublishMessage], - ): js.Promise[PubSubBulkPublishResponse] = js.native - -/** `PubSubPublishOptions` (`types/pubsub/PubSubPublishOptions.type.ts`). */ -private[internal] final class PubSubPublishOptions( - val contentType: js.UndefOr[String] = js.undefined, - val metadata: js.UndefOr[js.Dictionary[String]] = js.undefined, -) extends js.Object - -/** Explicit-entry form of `PubSubBulkPublishMessage` (`types/pubsub/PubSubBulkPublishMessage.type.ts`); passing the - * explicit `{entryID, event, contentType, metadata}` shape (detected via `"event" in message`, `utils/Client.util.js` - * `getBulkPublishEntries`) keeps our entry IDs and content type authoritative. - */ -private[internal] final class PubSubBulkPublishMessage( - val entryID: String, - val event: js.Any, - val contentType: js.UndefOr[String] = js.undefined, -) extends js.Object - -/** `PubSubBulkPublishResponse` (`types/pubsub/PubSubBulkPublishResponse.type.ts`). */ -@js.native -private[internal] trait PubSubBulkPublishResponse extends js.Object: - def failedMessages: js.Array[PubSubBulkPublishFailedMessage] = js.native - -@js.native -private[internal] trait PubSubBulkPublishFailedMessage extends js.Object: - def message: PubSubBulkPublishMessage = js.native - def error: js.Error = js.native - -// --------------------------------------------------------------------------- -// binding / invoker / secret (interfaces/Client/IClient{Binding,Invoker,Secret}.ts) -// --------------------------------------------------------------------------- - -@js.native -private[internal] trait BindingClient extends js.Object: - def send(bindingName: String, operation: String, data: js.Any, metadata: js.Dictionary[String]): js.Promise[js.Any] = - js.native - -@js.native -private[internal] trait InvokerClient extends js.Object: - def invoke( - appId: String, - methodName: String, - method: String, - data: js.UndefOr[js.Any], - options: InvokerOptions, - ): js.Promise[js.Any] = js.native - -/** `InvokerOptions` (`types/InvokerOptions.type.ts`): extra HTTP headers for the invocation. */ -private[internal] final class InvokerOptions( - val headers: js.UndefOr[js.Dictionary[String]] = js.undefined, -) extends js.Object - -@js.native -private[internal] trait SecretClient extends js.Object: - /** `metadata` is a pre-rendered query string (e.g. `"metadata.version_id=15"`), appended verbatim after `?` — see - * `implementation/Client/HTTPClient/secret.js`. - */ - def get(secretStoreName: String, key: String, metadata: String): js.Promise[js.Any] = js.native - def get(secretStoreName: String, key: String): js.Promise[js.Any] = js.native - def getBulk(secretStoreName: String): js.Promise[js.Any] = js.native - -// --------------------------------------------------------------------------- -// configuration (gRPC-only: implementation/Client/GRPCClient/configuration.js; -// the HTTP implementation throws HTTPNotSupportedError) -// --------------------------------------------------------------------------- - -@js.native -private[internal] trait ConfigurationClient extends js.Object: - def get( - storeName: String, - keys: js.Array[String], - metadata: js.Dictionary[String], - ): js.Promise[GetConfigurationResponse] = js.native - def subscribeWithMetadata( - storeName: String, - keys: js.Array[String], - metadata: js.Dictionary[String], - cb: js.Function1[SubscribeConfigurationResponse, js.Promise[Unit]], - ): js.Promise[ConfigurationSubscription] = js.native - -/** `GetConfigurationResponse` / `SubscribeConfigurationResponse`: both are `{items: {[key]: ConfigurationItem}}`. */ -@js.native -private[internal] trait GetConfigurationResponse extends js.Object: - def items: js.Dictionary[ConfigurationItemJs] = js.native - -@js.native -private[internal] trait SubscribeConfigurationResponse extends js.Object: - def items: js.Dictionary[ConfigurationItemJs] = js.native - -/** `types/configuration/ConfigurationItem.d.ts`, built by `createConfigurationType` (`utils/Client.util.js`) from the - * protobuf response. `value`/`version` are typed defensively as optional: proto3 string defaults make them `""` in - * practice, mirroring how the JVM impl treats `null` as `""`. - */ -@js.native -private[internal] trait ConfigurationItemJs extends js.Object: - def value: js.UndefOr[String] = js.native - def version: js.UndefOr[String] = js.native - def metadata: js.UndefOr[js.Dictionary[String]] = js.native - -/** `SubscribeConfigurationStream`: handle returned by `subscribe*`; `stop()` is an async arrow function (it aborts the - * stream and sends the explicit unsubscribe call), hence the `js.Promise[Unit]` result. - */ -@js.native -private[internal] trait ConfigurationSubscription extends js.Object: - def stop(): js.Promise[Unit] = js.native - -// --------------------------------------------------------------------------- -// lock (interfaces/Client/IClientLock.ts; v1.0-alpha1 HTTP endpoints) -// --------------------------------------------------------------------------- - -@js.native -private[internal] trait LockClient extends js.Object: - def lock( - storeName: String, - resourceId: String, - lockOwner: String, - expiryInSeconds: Int, - ): js.Promise[LockResponse] = js.native - def unlock(storeName: String, resourceId: String, lockOwner: String): js.Promise[UnlockResponse] = js.native - -@js.native -private[internal] trait LockResponse extends js.Object: - def success: js.UndefOr[Boolean] = js.native - -/** `UnlockResponse`: `status` is the numeric `LockStatus` enum — Success = 0, LockDoesNotExist = 1, LockBelongsToOthers = - * 2, InternalError = 3 (`implementation/Client/HTTPClient/lock.js` `_statusToLockStatus`). - */ -@js.native -private[internal] trait UnlockResponse extends js.Object: - def status: js.UndefOr[Int] = js.native - -// --------------------------------------------------------------------------- -// crypto (gRPC-only: implementation/Client/GRPCClient/crypto.js; the HTTP -// implementation throws HTTPNotSupportedError) -// --------------------------------------------------------------------------- - -@js.native -private[internal] trait CryptoClient extends js.Object: - /** The buffered overload: passing `inData` (any `ArrayBufferView` is accepted by the SDK's `toArrayBuffer`) makes - * `processStream` collect the response stream into a single Node `Buffer` (a `Uint8Array` subclass). The - * zero-`inData` Duplex-stream overload is deliberately not facaded. - */ - def encrypt(inData: js.typedarray.ArrayBufferView, opts: EncryptRequest): js.Promise[js.typedarray.Uint8Array] = - js.native - def decrypt(inData: js.typedarray.ArrayBufferView, opts: DecryptRequest): js.Promise[js.typedarray.Uint8Array] = - js.native - -/** `EncryptRequest` (`types/crypto/Requests.ts`); `keyWrapAlgorithm` is a TS string union, plain `String` at runtime. - */ -private[internal] final class EncryptRequest( - val componentName: String, - val keyName: String, - val keyWrapAlgorithm: String, -) extends js.Object - -/** `DecryptRequest` (`types/crypto/Requests.ts`): the ciphertext embeds the key reference, so only the component is - * required — same contract the JVM impl documents on `CryptoCapabilityImpl.decrypt`. - */ -private[internal] final class DecryptRequest( - val componentName: String, -) extends js.Object - -// --------------------------------------------------------------------------- -// health (interfaces/Client/IClientHealth.ts) — declared for completeness of -// the client seam; dapr4s does not currently call it (the SDK's sub-clients -// await sidecar health themselves on first use) -// --------------------------------------------------------------------------- - -@js.native -private[internal] trait HealthClient extends js.Object: - def isHealthy(): js.Promise[Boolean] = js.native diff --git a/src/js/internal/facade/Express.scala b/src/js/internal/facade/Express.scala deleted file mode 100644 index 0b50b8a..0000000 --- a/src/js/internal/facade/Express.scala +++ /dev/null @@ -1,169 +0,0 @@ -//> using target.platform "scala-js" -package dapr4s.internal.facade - -import scala.scalajs.js -import scala.scalajs.js.annotation.{JSGlobal, JSImport} - -// --------------------------------------------------------------------------- -// Scala.js facades for express 4 (a dependency of `@dapr/dapr`, so always -// present in node_modules) plus the two Node builtins the server lifecycle -// needs (`http.Server`, `process`). Used exclusively by -// `dapr4s.internal.DaprAppServer` — the JS twin of the JVM app-channel server, -// which hand-rolls the Dapr app-channel HTTP protocol instead of using the -// SDK's `DaprServer` (whose callbacks strip the CloudEvent envelope and -// constrain HTTP verbs). -// -// Signatures verified against the installed sources in node_modules/express -// (v4.22.2): lib/express.js, lib/application.js, lib/request.js, -// lib/response.js, lib/router/. Runtime-verified by a packaged Scala.js smoke -// app under BOTH CommonJS and ES module kinds. -// --------------------------------------------------------------------------- - -/** An express route handler. Express invokes handlers with `(req, res, next)`; we never call `next` from a terminal - * route handler, and a JS function created from a `js.Function2` lambda simply ignores the extra argument (standard - * JavaScript arity semantics). - */ -private[internal] type ExpressHandler = js.Function2[ExpressRequest, ExpressResponse, Unit] - -/** An express middleware function `(req, res, next)`. Express distinguishes error-handling middleware by - * `fn.length == 4`, so a 3-parameter function is always treated as ordinary middleware. - */ -private[internal] type ExpressMiddleware = js.Function3[ExpressRequest, ExpressResponse, js.Function0[Unit], Unit] - -/** Facade for the express module itself (`lib/express.js`). - * - * ==Why `JSImport.Default` (and not `Namespace`)== - * - * express is a classic CommonJS module: `module.exports = createApplication` — a '''callable function''' that also - * carries the middleware factories (`text`, `json`, ...) as properties. The two Scala.js module kinds bind a default - * import differently: - * - * - under `jsModuleKind commonjs`, Scala.js resolves `JSImport.Default` through its `$moduleDefault` interop helper - * (`m.__esModule ? m.default : m`); express sets no `__esModule` flag, so we get `module.exports` itself; - * - under `jsModuleKind es` (the Wasm/JSPI production target), `import { default as e } from "express"` binds Node's - * CJS↔ESM interop default, which is again `module.exports`. - * - * `JSImport.Namespace` would break under ES modules: an `import * as ns` namespace object is '''never callable''', so - * `express()` would throw `TypeError: ns is not a function`. `Default` is the only binding that yields the callable - * function under both module kinds (verified at runtime under both). - * - * 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 Express extends js.Object: - - /** `express()` — create an application (`lib/express.js` `createApplication`). */ - def apply(): ExpressApp = 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 `ExpressRequest.body`. - */ - def text(options: ExpressTextOptions): ExpressMiddleware = js.native - -/** Options for [[Express.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 - -/** The express application (`lib/application.js`). The HTTP-verb methods are generated by the `methods.forEach` block - * (line 489); each registers a route handler for that verb only. We always pass a path AND a handler, so the - * single-argument `app.get(setting)` settings-getter overload is never hit. - */ -@js.native -private[internal] trait ExpressApp extends js.Object: - - /** Mount application-wide middleware (`app.use(fn)`); runs in registration order before/after routes. */ - def use(middleware: ExpressMiddleware): Unit = js.native - - def get(path: String, handler: ExpressHandler): Unit = js.native - def post(path: String, handler: ExpressHandler): Unit = js.native - def put(path: String, handler: ExpressHandler): Unit = js.native - def delete(path: String, handler: ExpressHandler): Unit = js.native - - /** Register `handler` for the path under '''every''' HTTP method (`app.all`, line 514). */ - def all(path: String, handler: ExpressHandler): Unit = js.native - - /** `app.listen` delegates verbatim to Node's `net.Server.listen` (`http.createServer(this)` + `server.listen.apply`, - * line 633). The `(port, backlog, callback)` arity is supported by Node's argument normalisation: a numeric second - * argument is taken as the backlog (`toNumber(args[1])` in `net.js`). Returns the underlying `http.Server`. - */ - def listen(port: Int, callback: js.Function0[Unit]): NodeHttpServer = js.native - def listen(port: Int, backlog: Int, callback: js.Function0[Unit]): NodeHttpServer = js.native - -/** The express request (`lib/request.js`, augmented by the router). Only the members the dispatch layer reads. */ -@js.native -private[internal] trait ExpressRequest extends js.Object: - - /** Named route parameters (`:name` segments), URI-decoded by the router (`lib/router/layer.js` `decode_param`). */ - def params: js.Dictionary[String] = js.native - - /** The parsed body. With the [[Express.text]] middleware mounted this is the raw body '''string''' — except for - * requests body-parser skips (no body / no `Content-Type`), where it is the `{}` placeholder object body-parser - * assigns unconditionally (`req.body = req.body || {}`). Callers must therefore type-test for `String`. - */ - def body: js.Any = js.native - - /** The HTTP verb, upper-case (Node `IncomingMessage.method`). */ - def method: String = js.native - - /** The URL pathname, query string excluded (getter at `lib/request.js` line 412). */ - def path: String = js.native - - /** `req.get(name)`/`req.header(name)`: the request header, or `undefined` when absent. */ - def get(name: String): js.UndefOr[String] = js.native - -/** The express response (`lib/response.js`). Only the members the dispatch layer writes. */ -@js.native -private[internal] trait ExpressResponse extends js.Object: - - /** Set the response status code (line 67); returns `this` for chaining. */ - def status(code: Int): this.type = js.native - - /** Send a string body and finish the response (line 111). Sets `Content-Length`; the content type defaults to - * `text/html` for strings unless set beforehand via [[`type`]]. - */ - def send(body: String): Unit = js.native - - /** `res.type(t)` (line 619): set `Content-Type`; values containing `/` are used verbatim. */ - def `type`(contentType: String): this.type = js.native - - /** Finish the response without a body (inherited Node `ServerResponse.end`) — the express twin of the JVM's - * `exchange.sendResponseHeaders(code, -1)`. - */ - def end(): Unit = js.native - -/** The Node `http.Server` returned by [[ExpressApp.listen]] — only the lifecycle members the shutdown path needs. */ -@js.native -private[internal] trait NodeHttpServer extends js.Object: - - /** Stop accepting new connections; `callback` fires once all in-flight connections have closed. */ - def close(callback: js.Function0[Unit]): Unit = js.native - - /** Subscribe to a server event; `"error"` delivers bind failures (e.g. `EADDRINUSE`) as an `Error`. */ - def on(event: String, listener: js.Function1[js.Error, Unit]): js.Any = js.native - -/** The Node `process` global — signal handling and explicit exit for the shutdown path. */ -@js.native -@JSGlobal("process") -private[internal] object NodeProcess extends js.Object: - - /** Register a signal listener (`"SIGINT"`, `"SIGTERM"`). Once registered, Node's default terminate-on-signal - * behaviour is replaced — the listener '''must''' eventually call [[exit]] itself. - */ - def on(event: String, listener: js.Function0[Unit]): js.Any = js.native - - /** Terminate the process with the given exit code. */ - def exit(code: Int): Nothing = js.native diff --git a/src/js/internal/facade/ExpressModule.scala b/src/js/internal/facade/ExpressModule.scala new file mode 100644 index 0000000..aecf7cd --- /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 typings.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 `typings.*` +// packages (see js-deps.scala). This file exists because ScalablyTyped cannot +// express two members of the express module object: +// +// 1. `typings.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. `typings.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/js/internal/facade/NodeCrypto.scala b/src/js/internal/facade/NodeCrypto.scala deleted file mode 100644 index 9bcd130..0000000 --- a/src/js/internal/facade/NodeCrypto.scala +++ /dev/null @@ -1,25 +0,0 @@ -//> using target.platform "scala-js" -package dapr4s.internal.facade - -import scala.scalajs.js -import scala.scalajs.js.annotation.JSImport - -/** Facade for the Node.js built-in `node:crypto` module — only the one-shot hashing subset needed by the deterministic - * `WorkflowContext.newUuid` implementation (`dapr4s.internal.WorkflowContextImpl` on Scala.js). - * - * The JS internal layer already requires Node (the Dapr JS SDK itself is Node-only), so depending on a Node built-in - * here adds no new platform constraint. `java.security.MessageDigest` is not part of the Scala.js javalib, which is - * why SHA-1 comes from the host platform instead. - */ -@js.native -@JSImport("node:crypto", JSImport.Namespace) -private[internal] object NodeCrypto extends js.Object: - def createHash(algorithm: String): NodeHash = js.native - -/** The `Hash` object returned by `crypto.createHash` — used in the chained one-shot form - * `createHash("sha1").update(data, "utf8").digest("hex")`. - */ -@js.native -private[internal] trait NodeHash extends js.Object: - def update(data: String, inputEncoding: String): NodeHash = js.native - def digest(encoding: String): String = js.native diff --git a/src/js/internal/facade/NodeFetch.scala b/src/js/internal/facade/NodeFetch.scala deleted file mode 100644 index 6d90c14..0000000 --- a/src/js/internal/facade/NodeFetch.scala +++ /dev/null @@ -1,37 +0,0 @@ -//> using target.platform "scala-js" -package dapr4s.internal.facade - -import scala.scalajs.js -import scala.scalajs.js.annotation.JSGlobalScope - -/** Facade for the WHATWG `fetch` available as a Node global since Node 18 (the same floor the Dapr JS SDK requires). - * - * Used by the parts of the JS internal layer that must talk to the sidecar HTTP API directly because the SDK cannot - * express the operation (see [[dapr4s.internal.ActorCapabilityImpl]] and - * [[dapr4s.internal.StateCapabilityImpl.getWithETag]]) — the JS analogue of the JVM `HttpActorContext` raw - * `HttpURLConnection` precedent. - */ -@js.native -@JSGlobalScope -private[internal] object NodeGlobals extends js.Object: - def fetch(url: String, init: FetchRequestInit): js.Promise[FetchResponse] = js.native - -/** The `RequestInit` subset we need. */ -private[internal] final class FetchRequestInit( - val method: String, - val headers: js.Dictionary[String], - val body: js.UndefOr[String] = js.undefined, -) extends js.Object - -/** The `Response` subset we need. `headers.get(name)` returns `null` for absent headers — declared as `String | Null` - * so explicit-nulls forces callers to handle absence. - */ -@js.native -private[internal] trait FetchResponse extends js.Object: - def status: Int = js.native - def text(): js.Promise[String] = js.native - def headers: FetchHeaders = js.native - -@js.native -private[internal] trait FetchHeaders extends js.Object: - def get(name: String): String | Null = js.native diff --git a/src/js/internal/facade/WorkflowSdk.scala b/src/js/internal/facade/WorkflowSdk.scala deleted file mode 100644 index 4b662b4..0000000 --- a/src/js/internal/facade/WorkflowSdk.scala +++ /dev/null @@ -1,177 +0,0 @@ -//> using target.platform "scala-js" -package dapr4s.internal.facade - -import scala.scalajs.js -import scala.scalajs.js.annotation.JSImport - -// --------------------------------------------------------------------------- -// Facades for the workflow management client of `@dapr/dapr`. -// -// `DaprWorkflowClient` (workflow/client/DaprWorkflowClient.ts) talks gRPC -// directly to the sidecar via the vendored durabletask `TaskHubGrpcClient` -// (workflow/internal/durabletask/client/client.js) — it is the proper workflow -// client; the `client.workflow` building block on `DaprClient` is HTTP-only, -// deprecated-shaped, and not facaded here. -// --------------------------------------------------------------------------- - -/** Facade for `DaprWorkflowClient` (root export of `@dapr/dapr`). - * - * Inputs/outputs/payloads are `JSON.stringify`-ed by the vendored client (`client.js`: `scheduleNewOrchestration`, - * `raiseOrchestrationEvent`, `terminateOrchestration`), so callers control the wire format by choosing what JS value - * to pass — see [[dapr4s.internal.WorkflowCapabilityImpl]] for the JVM-parity rules. - */ -@js.native -@JSImport("@dapr/dapr", "DaprWorkflowClient") -private[dapr4s] class DaprWorkflowClient(options: WorkflowClientOptions) extends js.Object: - def scheduleNewWorkflow( - workflow: String, - input: js.UndefOr[js.Any], - instanceId: js.UndefOr[String], - ): js.Promise[String] = js.native - def terminateWorkflow(workflowInstanceId: String, output: js.Any | Null): js.Promise[Unit] = js.native - def getWorkflowState( - workflowInstanceId: String, - getInputsAndOutputs: Boolean, - ): js.Promise[js.UndefOr[WorkflowState]] = js.native - def waitForWorkflowCompletion( - workflowInstanceId: String, - fetchPayloads: Boolean, - timeoutInSeconds: Double, - ): js.Promise[js.UndefOr[WorkflowState]] = js.native - def raiseEvent(workflowInstanceId: String, eventName: String, eventPayload: js.Any): js.Promise[Unit] = js.native - def purgeWorkflow(workflowInstanceId: String): js.Promise[Boolean] = js.native - def suspendWorkflow(workflowInstanceId: String): js.Promise[Unit] = js.native - def resumeWorkflow(workflowInstanceId: String): js.Promise[Unit] = js.native - def stop(): js.Promise[Unit] = js.native - -/** Facade for `WorkflowClientOptions` (`types/workflow/WorkflowClientOption.ts`). All fields optional; the endpoint is - * resolved as `${daprHost}:${daprPort}` through `GrpcEndpoint` exactly like `GRPCClient` does - * (`workflow/internal/index.js` `generateEndpoint`). - */ -private[dapr4s] final class WorkflowClientOptions( - val daprHost: js.UndefOr[String] = js.undefined, - val daprPort: js.UndefOr[String] = js.undefined, - val daprApiToken: js.UndefOr[String] = js.undefined, -) extends js.Object - -/** Facade for `WorkflowState` (`workflow/client/WorkflowState.ts`) — a class with getters; modelled structurally - * because we only ever consume instances returned by [[DaprWorkflowClient]]. - * - * `runtimeStatus` is the numeric [[WorkflowRuntimeStatus]] enum. `createdAt`/`lastUpdatedAt` are JS `Date`s built from - * the protobuf timestamps (`workflow/internal/durabletask/orchestration/index.js`). `serializedInput`/`Output` are - * JSON strings, `undefined` when payload fetching was off or the value is absent. - */ -@js.native -private[internal] trait WorkflowState extends js.Object: - def name: String = js.native - def instanceId: String = js.native - def runtimeStatus: Int = js.native - def createdAt: js.Date = js.native - def lastUpdatedAt: js.Date = js.native - def serializedInput: js.UndefOr[String] = js.native - def serializedOutput: js.UndefOr[String] = js.native - -/** Facade for `WorkflowRuntime` (root export of `@dapr/dapr`, `workflow/runtime/WorkflowRuntime.ts`) — the server-side - * workflow/activity host. It shares [[WorkflowClientOptions]] with [[DaprWorkflowClient]] (same `generateEndpoint` + - * API-token interceptor wiring) and drives the vendored durabletask `TaskHubGrpcWorker`. - * - * Lifecycle facts verified in the vendored 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 `workflow` callback receives the '''public''' [[SdkWorkflowContext]] 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 - * [[dapr4s.internal.WorkflowCoroutine]] hands back. - * - * The activity callback receives [[SdkWorkflowActivityContext]] 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. - */ -@js.native -@JSImport("@dapr/dapr", "WorkflowRuntime") -private[internal] class WorkflowRuntime(options: WorkflowClientOptions) extends js.Object: - def registerWorkflowWithName( - name: String, - workflow: js.Function2[SdkWorkflowContext, js.Any, js.Any], - ): WorkflowRuntime = js.native - def registerActivityWithName( - name: String, - fn: js.Function2[SdkWorkflowActivityContext, js.Any, js.Any], - ): WorkflowRuntime = js.native - def start(): js.Promise[Unit] = js.native - def stop(): js.Promise[Unit] = js.native - -/** Facade for the public `WorkflowContext` wrapper class (`workflow/runtime/WorkflowContext.ts`) handed to registered - * workflow functions. Structural (no `@JSImport`): instances are only ever received from [[WorkflowRuntime]]. - * - * Named `Sdk*` (unlike the other facades, which reuse the SDK names) because the natural names collide with the dapr4s - * public types `WorkflowContext`/`Task` that the very same implementation files must also reference. - * - * Only the members dapr4s calls are declared. Verified against `WorkflowContext.js` + the inner - * `worker/runtime-orchestration-context.js`: - * - `createTimer` accepts a JS `Date` or a '''number of seconds''' (`fireAt * 1000` is added to the deterministic - * `currentUtcDateTime` when a non-`Date` is passed) — declared here with the seconds overload only. - * - `callActivity` accepts the activity name or function; dapr4s always passes the registered name (string). - * - `whenAny` returns a `WhenAnyTask` whose result is the first-completed '''child `Task` object''' (not its value) - * — see `task/when-any-task.js` `onChildCompleted`. - * - `continueAsNew(newInput, saveEvents)` only records state (`setContinuedAsNew`); unlike the Java SDK it does not - * throw — the dapr4s impl adds the stack-unwinding signal itself (see `WorkflowContextImpl.continueAsNew`). - */ -@js.native -private[internal] trait SdkWorkflowContext extends js.Object: - def getWorkflowInstanceId(): String = js.native - def getCurrentUtcDateTime(): js.Date = js.native - def isReplaying(): Boolean = js.native - def createTimer(fireAtSeconds: Double): SdkTask = js.native - def callActivity(activity: String, input: js.Any): SdkTask = js.native - def waitForExternalEvent(name: String): SdkTask = js.native - def continueAsNew(newInput: js.Any, saveEvents: Boolean): Unit = js.native - def whenAny(tasks: js.Array[SdkTask]): SdkTask = js.native - -/** Facade for the vendored durabletask `Task` base class (`workflow/internal/durabletask/task/task.js`) — the values - * the orchestration executor accepts as generator yields (`runtime-orchestration-context.js` checks `value instanceof - * Task`, so dapr4s must yield these very instances, never wrappers). Structural: instances are only ever produced by - * [[SdkWorkflowContext]] methods. - * - * `isComplete`/`isFailed` are JS getter properties (declared parameterless). `getResult()` returns the completed value - * and '''throws''' the stored `TaskFailedError` when the task failed. There is no cancellation concept in the JS SDK's - * task model (the Java SDK's `isCancelled` has no counterpart). - */ -@js.native -private[internal] trait SdkTask extends js.Object: - def isComplete: Boolean = js.native - def isFailed: Boolean = js.native - def getResult(): js.Any = js.native - -/** Facade for the public `WorkflowActivityContext` wrapper (`workflow/runtime/WorkflowActivityContext.ts`) handed to - * registered activity functions. Structural; dapr4s does not currently read it (activity input arrives as the second - * callback argument), but the members are declared for completeness of the seam. - */ -@js.native -private[internal] trait SdkWorkflowActivityContext extends js.Object: - def getWorkflowInstanceId(): String = js.native - def getWorkflowActivityId(): Double = js.native - -/** Facade for the numeric `WorkflowRuntimeStatus` enum (`workflow/runtime/WorkflowRuntimeStatus.ts`): RUNNING = 0, - * COMPLETED = 1, CONTINUED_AS_NEW = 2, FAILED = 3, TERMINATED = 5, PENDING = 6, SUSPENDED = 7. Note there is no - * CANCELED member (protobuf value 4) — the JS SDK omits it. Values are read off the real enum object rather than - * hardcoded, so a renumbering upstream cannot silently corrupt the mapping. - */ -@js.native -@JSImport("@dapr/dapr", "WorkflowRuntimeStatus") -private[internal] object WorkflowRuntimeStatus extends js.Object: - val RUNNING: Int = js.native - val COMPLETED: Int = js.native - val CONTINUED_AS_NEW: Int = js.native - val FAILED: Int = js.native - val TERMINATED: Int = js.native - val PENDING: Int = js.native - val SUSPENDED: Int = js.native From ca8c62dc297812b84c6c354d9a7f2da5a8caf482 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Fri, 12 Jun 2026 02:00:57 +0200 Subject: [PATCH 09/17] test: real Scala.js integration tests on Wasm+JSPI against a live sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 munit suites (test/js/integration/, 26 tests) exercising the real @dapr/dapr SDK on Node 25: state (incl. genuine stale-etag conflict — Redis etags are integers, fabricated strings get 400 not 409), pubsub roundtrip through a served subscription, invoke echo, secrets, lock, configuration (gRPC), actors (client invoke -> hosted actor -> actor state), workflows (activity-doubled output + raiseEvent through a gated workflow). Harness: - scripts/js-integration-env.sh: redis + placement + scheduler + daprd 1.17 (host network, non-default ports) + the Wasm-packaged JsTestServer; down() also pkills orphaned servers by dist path (a stale server holds the app port, the new one dies EADDRINUSE, and workflow tests then time out against the stale server's dead workflow worker) - scripts/wasm-test.sh: tolerates exactly the known scala-cli wasm cleanup bug (DirectoryNotEmptyException after green runs) - scripts/test-js-integration.sh: one-command up -> test -> down - scripts/js-it/node-resolve-hook.mjs (+delegate): NODE_OPTIONS --import module-resolution hook — scala-cli runs the linked ES module from /tmp, where bare specifiers can't reach the repo's node_modules (ESM ignores NODE_PATH/CWD); the hook retries them against the repo root Notes: JsItEnv.uniqueId avoids java.util.UUID.randomUUID (does not link on Scala.js — needs SecureRandom); the plain-JS unit leg must exclude test/js/integration (orphan-await suites wedge the plain-JS linker); --test-only is ineffective on the JS test runner (unit suites run too, harmlessly). Co-Authored-By: Claude Fable 5 --- scripts/js-integration-env.sh | 168 ++++++++++++++++++ scripts/js-it/components/configstore.yaml | 12 ++ scripts/js-it/components/lockstore.yaml | 12 ++ scripts/js-it/components/pubsub.yaml | 12 ++ scripts/js-it/components/secretstore.yaml | 12 ++ scripts/js-it/components/statestore.yaml | 17 ++ scripts/js-it/node-resolve-delegate.mjs | 29 +++ scripts/js-it/node-resolve-hook.mjs | 14 ++ scripts/js-it/secrets.json | 4 + scripts/test-js-integration.sh | 52 ++++++ scripts/wasm-test.sh | 43 +++++ .../integration/ActorJsIntegrationTest.scala | 71 ++++++++ .../ConfigurationJsIntegrationTest.scala | 46 +++++ .../integration/InvokeJsIntegrationTest.scala | 60 +++++++ test/js/integration/JsItEnv.scala | 99 +++++++++++ test/js/integration/JsTestServer.scala | 79 ++++++++ .../integration/LockJsIntegrationTest.scala | 51 ++++++ .../integration/PubSubJsIntegrationTest.scala | 75 ++++++++ .../SecretsJsIntegrationTest.scala | 57 ++++++ .../integration/StateJsIntegrationTest.scala | 118 ++++++++++++ .../WorkflowJsIntegrationTest.scala | 69 +++++++ 21 files changed, 1100 insertions(+) create mode 100755 scripts/js-integration-env.sh create mode 100644 scripts/js-it/components/configstore.yaml create mode 100644 scripts/js-it/components/lockstore.yaml create mode 100644 scripts/js-it/components/pubsub.yaml create mode 100644 scripts/js-it/components/secretstore.yaml create mode 100644 scripts/js-it/components/statestore.yaml create mode 100644 scripts/js-it/node-resolve-delegate.mjs create mode 100644 scripts/js-it/node-resolve-hook.mjs create mode 100644 scripts/js-it/secrets.json create mode 100755 scripts/test-js-integration.sh create mode 100755 scripts/wasm-test.sh create mode 100644 test/js/integration/ActorJsIntegrationTest.scala create mode 100644 test/js/integration/ConfigurationJsIntegrationTest.scala create mode 100644 test/js/integration/InvokeJsIntegrationTest.scala create mode 100644 test/js/integration/JsItEnv.scala create mode 100644 test/js/integration/JsTestServer.scala create mode 100644 test/js/integration/LockJsIntegrationTest.scala create mode 100644 test/js/integration/PubSubJsIntegrationTest.scala create mode 100644 test/js/integration/SecretsJsIntegrationTest.scala create mode 100644 test/js/integration/StateJsIntegrationTest.scala create mode 100644 test/js/integration/WorkflowJsIntegrationTest.scala diff --git a/scripts/js-integration-env.sh b/scripts/js-integration-env.sh new file mode 100755 index 0000000..42bc4e2 --- /dev/null +++ b/scripts/js-integration-env.sh @@ -0,0 +1,168 @@ +#!/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 (see scripts/js-it/components/*.yaml). +# +# daprd 1.17 workflows REQUIRE the scheduler service (the workflow engine schedules its +# reminders there); actors require placement. Components: state.redis (actorStateStore=true), +# pubsub.redis, lock.redis, configuration.redis, secretstores.local.file — all under +# scripts/js-it/, mounted into the daprd container at /dapr4s-js-it. +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" + +log() { echo "[js-integration-env] $*" >&2; } +die() { log "ERROR: $*"; 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 +} + +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. + log "packaging JsTestServer (Wasm) -> $DIST_DIR" + mkdir -p "$WORK_DIR" + "$SCALA_CLI" --power package --test --js --js-emit-wasm --js-module-kind es "$ROOT" \ + --main-class dapr4s.test.integration.jsTestServerMain -o "$DIST_DIR" -f + + # -- 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 "$ROOT/scripts/js-it:/dapr4s-js-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-js-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-js-it-cfg-a "alpha||v1" \ + dapr4s-js-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/components/configstore.yaml b/scripts/js-it/components/configstore.yaml new file mode 100644 index 0000000..a91f52b --- /dev/null +++ b/scripts/js-it/components/configstore.yaml @@ -0,0 +1,12 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: configstore +spec: + type: configuration.redis + version: v1 + metadata: + - name: redisHost + value: localhost:6391 + - name: redisPassword + value: "" diff --git a/scripts/js-it/components/lockstore.yaml b/scripts/js-it/components/lockstore.yaml new file mode 100644 index 0000000..7b01931 --- /dev/null +++ b/scripts/js-it/components/lockstore.yaml @@ -0,0 +1,12 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: lockstore +spec: + type: lock.redis + version: v1 + metadata: + - name: redisHost + value: localhost:6391 + - name: redisPassword + value: "" diff --git a/scripts/js-it/components/pubsub.yaml b/scripts/js-it/components/pubsub.yaml new file mode 100644 index 0000000..7348a4f --- /dev/null +++ b/scripts/js-it/components/pubsub.yaml @@ -0,0 +1,12 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: pubsub +spec: + type: pubsub.redis + version: v1 + metadata: + - name: redisHost + value: localhost:6391 + - name: redisPassword + value: "" diff --git a/scripts/js-it/components/secretstore.yaml b/scripts/js-it/components/secretstore.yaml new file mode 100644 index 0000000..192afec --- /dev/null +++ b/scripts/js-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: scripts/js-it/ is mounted at /dapr4s-js-it + # (see scripts/js-integration-env.sh). + - name: secretsFile + value: /dapr4s-js-it/secrets.json diff --git a/scripts/js-it/components/statestore.yaml b/scripts/js-it/components/statestore.yaml new file mode 100644 index 0000000..7a64066 --- /dev/null +++ b/scripts/js-it/components/statestore.yaml @@ -0,0 +1,17 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: statestore +spec: + type: state.redis + version: v1 + metadata: + # The daprd container runs with --network host; redis publishes container port 6379 + # on host port 6391 (see scripts/js-integration-env.sh). + - name: redisHost + value: localhost:6391 + - name: redisPassword + value: "" + # The Counter actor and the workflow runtime both store their state here. + - name: actorStateStore + value: "true" 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/js-it/secrets.json b/scripts/js-it/secrets.json new file mode 100644 index 0000000..a1fe194 --- /dev/null +++ b/scripts/js-it/secrets.json @@ -0,0 +1,4 @@ +{ + "js-it-secret": "s3cr3t-js", + "another-secret": "other-value" +} diff --git a/scripts/test-js-integration.sh b/scripts/test-js-integration.sh new file mode 100755 index 0000000..e1b996a --- /dev/null +++ b/scripts/test-js-integration.sh @@ -0,0 +1,52 @@ +#!/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/scalajs-async-jspi notes). +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" + +"$ROOT/scripts/wasm-test.sh" \ + --power --js --js-emit-wasm --js-module-kind es "$ROOT" \ + --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/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..b7cab36 --- /dev/null +++ b/test/js/integration/ConfigurationJsIntegrationTest.scala @@ -0,0 +1,46 @@ +//> using target.platform "scala-js" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import munit.FunSuite +import scala.concurrent.duration.{Duration, DurationInt} +import scala.scalajs.js +import unsafeExceptions.canThrowAny +import JsItEnv.* + +/** [[ConfigurationCapability]] against a real `configuration.redis` component. The keys are seeded by + * `scripts/js-integration-env.sh up` via `docker exec ... redis-cli MSET` (Dapr's redis configuration store splits + * `value||version` into value + version). Configuration is gRPC-only in the JS SDK, so this is also the suite that + * exercises the lazily created gRPC-protocol client end to end. + */ +@scala.caps.assumeSafe +class ConfigurationJsIntegrationTest extends FunSuite: + + override def munitTimeout: Duration = 120.seconds + + test("configuration: get returns the seeded items with values and versions"): + js.async { + Dapr(clientConfig).run: + DaprCapability.configuration(ConfigStore) { + val keyA = ConfigurationKey("dapr4s-js-it-cfg-a") + val keyB = ConfigurationKey("dapr4s-js-it-cfg-b") + val items = ConfigurationCapability.get(Seq(keyA, keyB)) + val a = items.getOrElse(keyA, fail(s"missing $keyA in $items")) + val b = items.getOrElse(keyB, fail(s"missing $keyB in $items")) + assertEquals(a.value, ConfigurationValue("alpha")) + assertEquals(a.version, ConfigurationVersion("v1")) + assertEquals(b.value, ConfigurationValue("beta")) + assertEquals(b.version, ConfigurationVersion("v2")) + } + }.toFuture + + test("configuration: get for an unknown key returns no item for it"): + js.async { + Dapr(clientConfig).run: + DaprCapability.configuration(ConfigStore) { + val absent = ConfigurationKey(s"dapr4s-js-it-absent-${uniqueId()}") + val items = ConfigurationCapability.get(Seq(absent)) + assertEquals(items.get(absent), None) + } + }.toFuture diff --git a/test/js/integration/InvokeJsIntegrationTest.scala b/test/js/integration/InvokeJsIntegrationTest.scala new file mode 100644 index 0000000..7993fb6 --- /dev/null +++ b/test/js/integration/InvokeJsIntegrationTest.scala @@ -0,0 +1,60 @@ +//> using target.platform "scala-js" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import dapr4s.test.integration.apps.{CounterState, EchoService, IncrRequest} +import munit.FunSuite +import scala.concurrent.duration.{Duration, DurationInt} +import scala.scalajs.js +import unsafeExceptions.canThrowAny +import JsItEnv.* + +/** [[InvokeCapability]] end to end through the sidecar to the JS test server's invoke routes ([[JsItServerApp]]) — the + * Scala.js twin of [[InvokeCapabilityServerTest]], including the derived [[EchoService]] caller facade. + * + * The falsy-`0` test exercises the raw-fetch fallback in `InvokeCapabilityImpl` (the JS SDK silently drops JS-falsy + * request bodies — `if (params?.body)` in HTTPClient.js). + * + * The first call retries: daprd reports healthy slightly before the app channel finishes warming up, mirroring the + * startup polling the JVM twins do. + */ +@scala.caps.assumeSafe +class InvokeJsIntegrationTest extends FunSuite: + + override def munitTimeout: Duration = 120.seconds + + test("invoke: echo roundtrip via the test server"): + js.async { + Dapr(clientConfig).run: + DaprCapability.invoke { + val resp = retryUntilSuccess("echo through app channel") { + InvokeCapability.invoke(ServerAppId, InvokeMethodName("echo"), "hello-js")[String] + } + assertEquals(resp, "hello-js") + } + }.toFuture + + test("invoke: falsy body 0 reaches the handler via the raw-fetch fallback"): + js.async { + Dapr(clientConfig).run: + DaprCapability.invoke { + val resp = retryUntilSuccess("echo-int through app channel") { + InvokeCapability.invoke(ServerAppId, InvokeMethodName("echo-int"), 0)[Int] + } + assertEquals(resp, 0) + } + }.toFuture + + test("invoke: derived EchoService facade calls the matching server routes"): + js.async { + Dapr(clientConfig).run: + DaprCapability.invoke { + val service = EchoService(ServerAppId) + val echoed = retryUntilSuccess("derived echo through app channel") { + service.echo("derived-js") + } + assertEquals(echoed, "derived-js") + assertEquals(service.double(IncrRequest(21)), CounterState(42)) + } + }.toFuture diff --git a/test/js/integration/JsItEnv.scala b/test/js/integration/JsItEnv.scala new file mode 100644 index 0000000..3aa36b3 --- /dev/null +++ b/test/js/integration/JsItEnv.scala @@ -0,0 +1,99 @@ +//> 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 scripts/js-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") + + /** 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..361e950 --- /dev/null +++ b/test/js/integration/LockJsIntegrationTest.scala @@ -0,0 +1,51 @@ +//> using target.platform "scala-js" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import munit.FunSuite +import scala.concurrent.duration.{Duration, DurationInt} +import scala.scalajs.js +import unsafeExceptions.canThrowAny +import JsItEnv.* + +/** [[LockCapability]] against a real `lock.redis` component — the Scala.js twin of [[LockCapabilityServerTest]]. Unique + * resource IDs per test keep the shared sidecar contention-free across runs. + */ +@scala.caps.assumeSafe +class LockJsIntegrationTest extends FunSuite: + + override def munitTimeout: Duration = 120.seconds + + private def uniqueResource() = LockResourceId(s"js-it-res-${uniqueId()}") + private def uniqueOwner() = LockOwner(s"js-it-owner-${uniqueId()}") + + test("lock: tryLock on a free resource returns true"): + js.async { + Dapr(clientConfig).run: + DaprCapability.lock(LockStore) { + assert(LockCapability.tryLock(uniqueResource(), uniqueOwner(), 30.seconds)) + } + }.toFuture + + test("lock: tryLock on a held resource returns false"): + js.async { + Dapr(clientConfig).run: + DaprCapability.lock(LockStore) { + val res = uniqueResource() + assert(LockCapability.tryLock(res, uniqueOwner(), 30.seconds), "first tryLock should succeed") + assert(!LockCapability.tryLock(res, uniqueOwner(), 30.seconds), "second tryLock should be contended") + } + }.toFuture + + test("lock: unlock by the owner returns Success, re-unlock returns LockNotFound"): + js.async { + Dapr(clientConfig).run: + DaprCapability.lock(LockStore) { + val res = uniqueResource() + val owner = uniqueOwner() + assert(LockCapability.tryLock(res, owner, 30.seconds)) + assertEquals(LockCapability.unlock(res, owner), UnlockStatus.Success) + assertEquals(LockCapability.unlock(res, owner), UnlockStatus.LockNotFound) + } + }.toFuture 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..82a5a70 --- /dev/null +++ b/test/js/integration/SecretsJsIntegrationTest.scala @@ -0,0 +1,57 @@ +//> using target.platform "scala-js" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import munit.FunSuite +import scala.concurrent.duration.{Duration, DurationInt} +import scala.scalajs.js +import unsafeExceptions.canThrowAny +import JsItEnv.* + +/** [[SecretsCapability]] against a real `secretstores.local.file` component (seeded from `scripts/js-it/secrets.json`) + * — the Scala.js twin of [[SecretsCapabilityServerTest]]. + * + * A missing key THROWS rather than returning `None`: the local-file store answers 500, which rejects the SDK promise — + * the documented behaviour on both platforms (`SecretsCapabilityImpl`: "a sidecar error REJECTS the promise and + * propagates; None is returned only when the call succeeds but the response lacks the key"). + */ +@scala.caps.assumeSafe +class SecretsJsIntegrationTest extends FunSuite: + + override def munitTimeout: Duration = 120.seconds + + test("secrets: get for a seeded key returns Some"): + js.async { + Dapr(clientConfig).run: + DaprCapability.secrets(SecretStore) { + assertEquals(SecretsCapability.get(SecretKey("js-it-secret")), Some(SecretValue("s3cr3t-js"))) + assertEquals(SecretsCapability.get(SecretKey("another-secret")), Some(SecretValue("other-value"))) + } + }.toFuture + + test("secrets: getBulk contains the seeded keys"): + js.async { + Dapr(clientConfig).run: + DaprCapability.secrets(SecretStore) { + val bulk = SecretsCapability.getBulk() + // The bulk response nests {secretName: {key: value}}; dapr4s flattens to "name/key" compound keys. + assert( + bulk.exists { case (k, v) => k.value.contains("js-it-secret") && v.value == "s3cr3t-js" }, + s"expected js-it-secret in bulk result; got keys: ${bulk.keys.map(_.value).toList.sorted}", + ) + assert( + bulk.exists { case (k, v) => k.value.contains("another-secret") && v.value == "other-value" }, + "expected another-secret in bulk result", + ) + } + }.toFuture + + test("secrets: get for a missing key throws (local-file store answers 500)"): + js.async { + Dapr(clientConfig).run: + DaprCapability.secrets(SecretStore) { + val attempt = scala.util.Try(SecretsCapability.get(SecretKey(s"absent-${uniqueId()}"))) + assert(attempt.isFailure, s"expected a missing secret to throw, got: $attempt") + } + }.toFuture diff --git a/test/js/integration/StateJsIntegrationTest.scala b/test/js/integration/StateJsIntegrationTest.scala new file mode 100644 index 0000000..3deceb5 --- /dev/null +++ b/test/js/integration/StateJsIntegrationTest.scala @@ -0,0 +1,118 @@ +//> using target.platform "scala-js" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import munit.FunSuite +import scala.concurrent.duration.{Duration, DurationInt} +import scala.scalajs.js +import unsafeExceptions.canThrowAny +import JsItEnv.* + +/** [[StateCapability]] against a real `state.redis` component, on the Wasm+JSPI backend — the Scala.js twin of + * [[StateCapabilityServerTest]]'s coverage (minus the HTTP-dispatch wrapping: here the capability itself IS the thing + * under test, called directly inside `Dapr.run`). + * + * Every munit body is `js.async { ... }.toFuture` — never a raw `js.Promise`, which munit would NOT await (verified + * footgun: a vacuous pass). Requires the environment from `scripts/js-integration-env.sh up`. + */ +@scala.caps.assumeSafe +class StateJsIntegrationTest extends FunSuite: + + override def munitTimeout: Duration = 120.seconds + + private def uniqueKey() = StateStoreKey(s"js-it-k-${uniqueId()}") + + test("state: save then get returns the saved value"): + js.async { + Dapr(clientConfig).run: + DaprCapability.state(StateStore) { + val k = uniqueKey() + StateCapability.save(k, "hello-js") + assertEquals(StateCapability.get[String](k), Some("hello-js")) + } + }.toFuture + + test("state: get for a missing key returns None"): + js.async { + Dapr(clientConfig).run: + DaprCapability.state(StateStore) { + assertEquals(StateCapability.get[String](uniqueKey()), None) + } + }.toFuture + + test("state: getWithETag returns value and etag after save"): + js.async { + Dapr(clientConfig).run: + DaprCapability.state(StateStore) { + val k = uniqueKey() + StateCapability.save(k, "etagged") + val entry = StateCapability.getWithETag[String](k) + assertEquals(entry.value, Some("etagged")) + assert(entry.etag.isDefined, "ETag should be present after save") + } + }.toFuture + + test("state: saveWithETag succeeds with the current etag and conflicts with a wrong one"): + js.async { + Dapr(clientConfig).run: + DaprCapability.state(StateStore) { + val k = uniqueKey() + StateCapability.save(k, "v1") + val etag = StateCapability.getWithETag[String](k).etag.getOrElse(fail("expected an etag")) + assertEquals(StateCapability.saveWithETag(k, "v2", etag), None) + // The successful save bumped the server-side etag, so the one captured above is now + // STALE — a genuine optimistic-concurrency conflict. A fabricated string would not do + // here: Redis etags are integers, and daprd rejects a non-numeric etag with + // 400 ERR_STATE_SAVE (invalid etag value) instead of reporting a conflict. + assert( + StateCapability.saveWithETag(k, "v3", etag).isDefined, + "stale etag should yield a conflict", + ) + assertEquals(StateCapability.get[String](k), Some("v2")) + } + }.toFuture + + test("state: delete removes a key"): + js.async { + Dapr(clientConfig).run: + DaprCapability.state(StateStore) { + val k = uniqueKey() + StateCapability.save(k, "bye") + StateCapability.delete(k) + assertEquals(StateCapability.get[String](k), None) + } + }.toFuture + + test("state: saveBulk persists all entries and getBulk reads them (None for absent)"): + js.async { + Dapr(clientConfig).run: + DaprCapability.state(StateStore) { + val k1 = uniqueKey() + val k2 = uniqueKey() + val absent = uniqueKey() + StateCapability.saveBulk[String](List(k1 -> "alpha", k2 -> "beta")) + val results = StateCapability.getBulk[String](List(k1, k2, absent)) + assertEquals(results.get(k1).flatMap(_.value), Some("alpha")) + assertEquals(results.get(k2).flatMap(_.value), Some("beta")) + assertEquals(results.get(absent).flatMap(_.value), None) + } + }.toFuture + + test("state: transaction upserts and deletes atomically"): + js.async { + Dapr(clientConfig).run: + DaprCapability.state(StateStore) { + val kAdd = uniqueKey() + val kDel = uniqueKey() + 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) + } + }.toFuture 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 From cd60afd263c282843c7b8621de63298ac046d01e Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Fri, 12 Jun 2026 02:47:21 +0200 Subject: [PATCH 10/17] ci+docs: platform matrix, review fixes, documentation for the rework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI (.github/workflows/ci.yml): - one 'test' matrix job (legs jvm + js; Scala Native someday = one more include entry), each leg running compile + unit + integration tests - js leg: Node 25 (JSPI), npm ci, ScalablyTyped generation with an actions/cache keyed on package-lock.json + the generation script - publish needs [format, test], generates ST facades, publishes both platforms — no --exclude flags anywhere (target.platform-scoped deps) Review fixes (focused adversarial round, 11 confirmed findings): - rawInvoke: metadata headers now REPLACE colliding base headers (pairs-array HeadersInit appends and combines duplicates as 'v1, v2', which would corrupt Content-Type/dapr-api-token) - queryState: restored the absent-results guard (the SDK only substitutes {results: []} for an EMPTY body; a results-less JSON body passes through) - generate-st-facades.sh: skip guard now requires all three root jars (an interrupted run no longer wedges the build behind a single-jar marker) - js-integration-env.sh: failure diagnostics (server.log + docker logs tails) before dying — CI runners are discarded with the evidence otherwise - docs truthfulness: stale internal/js path, '--exclude not needed anywhere' overclaims qualified, wiki test --cross caveat, README scala-cli >= 1.14 note for the JS harness, broken wiki index link dropped (+ lint log entry) Docs: README (layout, ST consumer recipe, four test legs), DESIGN.md (platform-trait technique, ST pipeline, test architecture, refreshed structure tree), AGENTS.md (reconciled with ci.yml; platform-trait pattern documented), SPEC.allium path fix, wiki (new scalablytyped-with-scala-cli article; corrected the dep-leak claim — only test.dep leaks; JSPI field notes; isFirstAttempt worker bug; Redis integer-etag behaviour). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 123 ++++--- AGENTS.md | 151 ++++++--- README.md | 55 +++- docs/DESIGN.md | 311 ++++++++++-------- docs/SPEC-crypto-jobs-conversation.md | 4 +- docs/SPEC.allium | 2 +- docs/derivation.md | 6 +- docs/validation.md | 2 +- jvm-deps.scala | 3 +- scripts/generate-st-facades.sh | 13 +- scripts/js-integration-env.sh | 18 +- scripts/test-js-integration.sh | 2 +- src/js/internal/InvokeCapabilityImpl.scala | 9 +- src/js/internal/StateCapabilityImpl.scala | 10 +- wiki/dapr/dapr-js-sdk.md | 12 +- wiki/index.md | 8 +- wiki/log.md | 13 + wiki/scala-js/scala-js-async-jspi-wasm.md | 18 +- .../scala-js-cross-building-scala-cli.md | 27 +- wiki/scala-js/scalablytyped-with-scala-cli.md | 65 ++++ .../java-interop-safe-scala.md | 2 +- 21 files changed, 572 insertions(+), 282 deletions(-) create mode 100644 wiki/scala-js/scalablytyped-with-scala-cli.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 815d4ad..c7c8926 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,61 +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.*' - - test-js: - 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 - # Scala.js invocations must exclude jvm-deps.scala (the Dapr Java SDK + - # testcontainers) — scala-cli cannot scope dependency directives to a platform, - # so that file is the wall keeping JVM-only artifacts out of the JS build. - # The runner's default Node is fine: unit tests run on the plain JS backend - # (the orphan-js.await capability code is Wasm-only and unit tests don't link - # it), and no unit test loads @dapr/dapr, so no npm install is needed here. - - name: Compile (Scala.js) - run: scala-cli compile --js . --exclude jvm-deps.scala - - name: Unit tests (Scala.js) - run: scala-cli test --js . --exclude jvm-deps.scala - - integration-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - uses: coursier/cache-action@v8 - - uses: VirtusLab/scala-cli-setup@v1 - 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, test-js, integration-test] + needs: [format, test] if: github.event_name == 'push' runs-on: ubuntu-latest steps: @@ -92,20 +111,36 @@ 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 resolves the ScalablyTyped facade deps (js-deps.scala), + # so the artifacts must exist in ~/.ivy2/local here too. + - 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 }} - # Second invocation publishes the Scala.js artifact (dapr4s_sjs1_3); excluding - # jvm-deps.scala keeps the Dapr Java SDK out of its POM. + # Publishes dapr4s_sjs1_3. Its POM references the org.scalablytyped facade + # coordinates, which are NOT on Maven Central — downstream Scala.js users must run + # scripts/generate-st-facades.sh (same digests, deterministic) before linking; see + # README "Scala.js" and js-deps.scala. - name: Publish (Scala.js) - run: scala-cli publish --js . --exclude jvm-deps.scala + run: scala-cli publish --js . env: PUBLISH_USER: ${{ secrets.PUBLISH_USER }} PUBLISH_PASSWORD: ${{ secrets.PUBLISH_PASSWORD }} diff --git a/AGENTS.md b/AGENTS.md index 160a436..080ec21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,19 +25,25 @@ Both must stay in sync with the code at all times. tool hints that a newer nightly is available. - **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`, scoped to the JVM by their - `target.platform` directive, which is what keeps the `_sjs1_3` build/POM clean — no `--exclude` - flags needed): + (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 .` - Platform-specific sources carry per-file `//> using target.platform "jvm"`/`"scala-js"` - directives. `//> using jsEsVersionStr "es2017"` is required by `js.async`/`js.await`. + - `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. - **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). Run tests with - `scala-cli test . --test-only "*unit*"` for unit tests. + 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): @@ -47,14 +53,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.) - Cross deps use the `::version` (double-colon) form; `scala-java-time` provides `java.time` on - Scala.js (a thin JDK shim on the JVM). JVM-only deps (Dapr Java SDK, testcontainers) live in - `jvm-deps.scala`, not `project.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); the generated facade jars are declared in - `js-deps.scala`. + 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). --- @@ -178,12 +185,12 @@ Specific rules currently active in this codebase: - **State keys**: `StateStoreKey` (app-level `StateCapability`) vs. `ActorStateKey` (per-instance `ActorContext`/`ActorState`). ### SDK interop boundary (Java on JVM, @dapr/dapr on JS) -Everything in `src/internal/` is marked `@scala.caps.assumeSafe`. There are two platform walls -behind the same boundary: +Everything in the two `internal/` trees is marked `@scala.caps.assumeSafe`. There are two +platform walls behind the same boundary: -- `src/internal/` (excluding the `js/` subdirectory, 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 `internal/` or in any test file. +- `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** (`typings.daprDapr`, `typings.expressServeStaticCore`, @@ -198,6 +205,32 @@ behind the same boundary: 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 @@ -255,7 +288,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 @@ -266,31 +300,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). -- **Scala.js unit tests** (no Docker, no npm install needed): - `scala-cli test --js .`. These run on the **plain JS backend** under - Node — fine because unit tests never link the orphan-await capability code and never load - `@dapr/dapr`. Most unit tests cross-compile and run on both platforms; the jvm-tagged - exceptions are `SubscriberTest`, `BindingDispatchTest`, `JobDispatchTest`, and - `DaprServerTestBase` (they drive the JVM `DaprAppServer` over real HTTP on - `com.sun.net.httpserver`), plus `TestCodecs.scala` (Jackson — a Java SDK transitive dep) and - `TestDaprExtensions.scala`. `TestCodecsJs.scala` provides the same codec given names over ujson - so the shared tests run unchanged on JS. -- **Integration tests** (require Docker, **JVM-only for now** — every suite/harness file directly - under `test/integration/` is jvm-tagged; a Wasm+JSPI JS e2e is a documented follow-up). The - `DaprApp` fixtures in `test/integration/apps/` (all except the two `*Main.scala` entry points) - are deliberately **untagged and cross-compile** — `CapabilityHandlerTest` exercises them on - Scala.js, so do not jvm-tag them. Run: - `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. @@ -402,7 +451,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 JS integration-test suite in CI yet — the Scala.js layer is e2e-verified manually against a - real sidecar (see `docs/DESIGN.md`); the CI `test-js` job covers compilation and the shared unit - tests on the plain JS backend. +- 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 f94d848..c20ad8d 100644 --- a/README.md +++ b/README.md @@ -17,25 +17,34 @@ see only Scala types. ## Requirements - 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) +- [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) -- Node 25+ and `npm install @dapr/dapr` (only for running Scala.js apps that touch capabilities — see below) -- Docker (only for the integration tests, which spin up a real `daprd` sidecar + Redis - via testcontainers) +- 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 -```bash -scala-cli compile . -scala-cli test . --test-only 'dapr4s.test.unit.*' # unit tests -scala-cli test . --test-only 'dapr4s.test.integration.*' # needs Docker +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. -scala-cli compile --js . --exclude jvm-deps.scala # Scala.js -scala-cli test --js . --exclude jvm-deps.scala # Scala.js unit tests +```bash +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) ``` -Scala.js invocations must `--exclude jvm-deps.scala` (the file holding the JVM-only -Dapr Java SDK and testcontainers dependencies). +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 @@ -65,8 +74,10 @@ while the Node event loop keeps running. 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 throw `UnsupportedOperationException` because the Dapr JS -SDK has no API for them (use the JVM platform for those). +`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 @@ -96,6 +107,22 @@ def main(args: Array[String]): Unit = }: Unit ``` +### Scala.js facades: a one-time local generation step + +The `dapr4s_sjs1_3` POM references ScalablyTyped-generated facades of `@dapr/dapr` +(`org.scalablytyped::dapr__dapr` and friends, see `js-deps.scala`). These are **not on Maven +Central** — they live only in the local ivy repository (`~/.ivy2/local`), which scala-cli resolves +out of the box. Before your first build against dapr4s JS (and in CI), materialise them locally: + +1. get this repository's `package.json` + `package-lock.json` + `scripts/generate-st-facades.sh` + (all committed here — the converter inputs are `@dapr/dapr`, `@types/express`, `@types/node`, + with `typescript` needed by the converter itself), +2. run `npm ci`, then `scripts/generate-st-facades.sh`. + +The facade digests are deterministic in (package-lock.json, converter version, converter flags), +so the script reproduces exactly the coordinates the published POM references. It is idempotent +and skips instantly when the jars already exist. + 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. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7e621ad..1933859 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -6,7 +6,7 @@ A Scala 3 library that exposes every DAPR building block as a **tracked capabili **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. 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/internal/` and `src/internal/js/` respectively) — see the [Scala.js platform](#scalajs-platform) section. +**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: @@ -39,7 +39,7 @@ graph TB DA["DaprApp (case class)\n+ Subscription / InvokeRoute / BindingRoute"] end - subgraph "Internal Layer — JVM (@assumeSafe boundaries, src/internal/)" + 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)"] @@ -64,7 +64,7 @@ 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/internal/js/`): +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 @@ -73,7 +73,7 @@ graph TB API2["Public API: capability traits, DaprApp,\nopaque types, derivation macros"] end - subgraph "Internal Layer — Scala.js (@assumeSafe boundaries, src/internal/js/)" + 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"] @@ -105,7 +105,7 @@ Capability traits, opaque domain types, and the `Dapr(config).run` / `.serve` en ### Layer 2 — Internal implementations (`@assumeSafe`) -Non-safe-mode Scala that wraps the platform SDK calls — the Java SDK's `DaprClient` in `src/internal/`, the JS SDK's `DaprClient`/`DaprWorkflowClient`/`WorkflowRuntime` (plus express and raw `fetch`) in `src/internal/js/`. 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/` (both platforms) and `jvm/Dapr.scala`/`js/Dapr.scala`/`JsonCodec.scala` must use `@scala.caps.assumeSafe` and therefore cannot have the safe-mode import. @@ -130,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] @@ -292,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) @@ -322,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 = @@ -416,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 | |---|---|---| @@ -566,75 +566,89 @@ Internal catch clauses use `scala.util.control.NonFatal` to ensure fatal JVM err ## Project Structure (Scala CLI) -Platform tags: files marked `[jvm]` / `[js]` carry a `//> using target.platform` directive and exist on one platform only; untagged sources cross-compile. +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: platforms jvm + scala-js, nightly Scala, │ # compiler options, cross deps (scala-java-time; munit/upickle test deps) -├── jvm-deps.scala # JVM-only deps (Dapr Java SDK, testcontainers) — every Scala.js -│ # invocation passes --exclude jvm-deps.scala (see Scala.js platform section) -├── publish-conf.scala # CI publishing config (git:tag version, central, env credentials) -├── package.json # npm dep @dapr/dapr for the Scala.js layer (Node resolves it from the CWD) +├── 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-generated facade coordinates +│ # (org.scalablytyped::dapr__dapr/express/node, resolved from ~/.ivy2/local) +├── 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) +│ ├── 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, Job/Conversation models -│ ├── 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/JobRoute -│ ├── DaprCapability.scala # DaprCapability trait with ^{this} return types [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, ...) -│ ├── derivation/ # Macro derivation layer: per-capability derive engines (State, Publish, -│ │ # Invoke, Secrets, Configuration, Bindings, Crypto, Jobs, Subscriptions, +│ ├── 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, Forwarders, MacroSupport) -│ ├── jvm/ -│ │ └── Dapr.scala # JVM entry point: class Dapr(config) with .run + .serve [@assumeSafe] [jvm] -│ ├── js/ -│ │ └── Dapr.scala # Scala.js entry point: same public run/serve signatures -│ │ # + JS-only runAsync/serveAsync [@assumeSafe] [js] -│ └── internal/ # JVM internal layer — Java SDK confined here [all jvm] -│ ├── DaprCapabilityImpl.scala # DaprCapability implementation -│ ├── MonoOps.scala # Reactor Mono → blocking bridge (.toFuture().get()) -│ ├── FluxOps.scala # Reactor Flux subscription bridge (configuration subscribe) -│ ├── NullOps.scala # null-handling helpers -│ ├── Json.scala # shared Jackson mapper for internal protocol plumbing -│ ├── 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 -│ ├── ConversationCapabilityImpl.scala -│ ├── CryptoCapabilityImpl.scala -│ ├── JobsCapabilityImpl.scala -│ ├── HttpActorContext.scala -│ ├── WorkflowCapabilityImpl.scala -│ ├── WorkflowContextImpl.scala -│ ├── WorkflowBridges.scala # WorkflowBridge / WorkflowActivityBridge (Java SDK adapters) -│ └── js/ # Scala.js internal layer — JS SDK confined here; -│ │ # same package dapr4s.internal [all js] -│ ├── facade/ # @js.native facades (package dapr4s.internal.facade): -│ │ ├── DaprSdk.scala # DaprClient + options/enums (@dapr/dapr root exports) -│ │ ├── WorkflowSdk.scala # DaprWorkflowClient, WorkflowRuntime, SdkTask, contexts -│ │ ├── Express.scala # express 4 app/request/response + http.Server, process -│ │ ├── NodeFetch.scala # Node-global fetch -│ │ └── NodeCrypto.scala # node:crypto createHash (deterministic newUuid) +│ │ # 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 typings.* 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 -│ ├── StateCapabilityImpl.scala # … + Publish/Invoke/Secrets/Configuration/ -│ ├── ...CapabilityImpl.scala # Bindings/Lock/Crypto twins (HTTP or gRPC client) +│ ├── 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) @@ -643,60 +657,31 @@ dapr4s/ │ ├── WorkflowCoroutine.scala # AsyncGenerator coroutine bridge (see Scala.js platform section) │ └── WorkflowContextImpl.scala └── test/ - ├── TestCodecs.scala # shared test JsonCodec instances (Jackson) [jvm] - ├── TestCodecsJs.scala # same given names over ujson, so shared tests cross-run [js] - ├── TestDaprExtensions.scala # test-only Dapr.runWithEndpoints(http, grpc) helper [jvm] - ├── TestOptionCodec.scala - ├── unit/ # cross-platform unless tagged - │ ├── ModelsTest.scala - │ ├── JsonCodecTest.scala - │ ├── CharsetsTest.scala - │ ├── CCTest.scala # capture checking invariants (ScopeContainment, JsonCodec) - │ ├── DaprAppValidationTest.scala - │ ├── ActorDefinitionsTest.scala - │ ├── CapabilityDerivationTest.scala (+ CapabilityDerivationFixtures) - │ ├── InvokeDerivationTest.scala (+ DerivationFixtures) - │ ├── ServerRouteDerivationTest.scala - │ ├── WorkflowActivityDerivationTest.scala (+ WorkflowActivityDerivationFixtures) - │ ├── WorkflowEventsTest.scala - │ ├── CapabilityHandlerTest.scala - │ ├── StateCapabilityTest.scala # superseded by CapabilityHandlerTest (kept as a tombstone note) - │ ├── DaprServerTestBase.scala # drives the JVM DaprAppServer over real HTTP [jvm] - │ ├── SubscriberTest.scala # DaprAppServer dispatch logic [jvm] - │ ├── BindingDispatchTest.scala # [jvm] - │ └── JobDispatchTest.scala # [jvm] - └── integration/ # suites all [jvm]; apps/ cross-compiles except the Mains - ├── 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 - ├── JobsCapabilityServerTest.scala - ├── CryptoCapabilityServerTest.scala - ├── ConversationCapabilityServerTest.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()) [jvm] - ├── InventoryServiceMain.scala # [jvm] - ├── EchoServiceClient.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: TestDaprApp + DaprTestContainer harnesses, + │ │ # per-capability *CapabilityServerTest (State/Publish/Secrets/Lock/Actor/ + │ │ # Invoke/Workflow/Jobs/Crypto/Conversation), State/PubSub/Invoke/Secrets/ + │ │ # OrderService/InventoryService/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 suites against a live sidecar (see Scala.js platform section): + # State/PubSub/Invoke/Secrets/Configuration/Lock/Actor/Workflow + # JsIntegrationTests + JsTestServer (the served app) + JsItEnv (env twin) ``` --- @@ -709,7 +694,7 @@ dapr4s/ | JSON library | upickle | Pure Scala, Scala CLI friendly, automatic derivation | | 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` | -| SDK visibility | Zero — Java SDK confined to `internal/`, JS SDK (`@dapr/dapr`) confined to `internal/js/` | Users see only Scala types; easier to swap SDKs 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. | @@ -829,7 +814,7 @@ 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**, backed by the Dapr JS SDK (`@dapr/dapr`). +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 @@ -851,7 +836,7 @@ Consequences for JS consumers (documented on `src/js/Dapr.scala`): - `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** — a clean failure mode, not a runtime surprise. +**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 @@ -867,13 +852,40 @@ The JS SDK cannot serve all building blocks over one protocol (`configuration`/` | 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 | `UnsupportedOperationException` — the JS SDK has no jobs or conversation API; use the JVM platform | +| 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. @@ -891,17 +903,44 @@ The JS SDK's orchestration executor drives an **async generator** that yields th 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`. -### Build pattern: `jvm-deps.scala` + `--exclude` +### ScalablyTyped-generated facades -scala-cli cannot scope dependency directives to a platform — a `//> using dep` applies to every platform of the build, even from a `target.platform jvm`-tagged file. The Dapr Java SDK and testcontainers therefore live in `jvm-deps.scala` at the repo root, which every Scala.js invocation excludes: +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`) 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 `typings.*` facade jars published to the **local** ivy repository (`~/.ivy2/local/org.scalablytyped/...`), which `js-deps.scala` pins and scala-cli resolves with zero configuration. Generated code is **never committed** and never published remotely. -```bash -scala-cli compile --js . --exclude jvm-deps.scala -scala-cli test --js . --exclude jvm-deps.scala -scala-cli publish --js . --exclude jvm-deps.scala -``` +**Digest contract**: each coordinate's version is `-` (e.g. `3.18.0-d1e27c`), where the digest is deterministic in exactly (package-lock.json contents, converter version, converter flags). `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 `typings.*` directly. + +**Consumer story**: the published `dapr4s_sjs1_3` POM references the `org.scalablytyped` coordinates, which are **not on Maven Central**. Downstream Scala.js users must run the same generation (the script, `package.json` and `package-lock.json` all ship in this repository; the digests come out identical) to materialise the facades in their own `~/.ivy2/local` before resolving dapr4s JS. See the README's facade-generation recipe. + +### 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 | + +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`. Eight munit suites (26 tests) under `test/js/integration/` — state, pub/sub, invoke, secrets, configuration, lock, actors, workflows — 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: -Default (JVM) invocations include the file, so JVM workflows are unchanged. This keeps the published `_sjs1_3` POM free of JVM-only artifacts. Platform-specific sources carry per-file `//> using target.platform` directives (see Project Structure). Building the JS platform requires scala-cli >= 1.13.0. +- **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`). ### Known platform divergences @@ -911,7 +950,7 @@ Default (JVM) invocations include the file, so JVM workflows are unchanged. This | `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 | `UnsupportedOperationException` (absent from the JS SDK) | +| `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) | 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 9152657..23425da 100644 --- a/docs/SPEC.allium +++ b/docs/SPEC.allium @@ -29,7 +29,7 @@ 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/internal/js/), equally + -- (@dapr/dapr DaprClient + DaprWorkflowClient, in src/js/internal/), equally -- opaque to user code and equally managed inside @assumeSafe boundaries. } 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/jvm-deps.scala b/jvm-deps.scala index 254fb69..1709469 100644 --- a/jvm-deps.scala +++ b/jvm-deps.scala @@ -4,7 +4,8 @@ // 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 anywhere any more.) +// 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 diff --git a/scripts/generate-st-facades.sh b/scripts/generate-st-facades.sh index d735471..7d8a029 100755 --- a/scripts/generate-st-facades.sh +++ b/scripts/generate-st-facades.sh @@ -41,9 +41,16 @@ for entry in "dapr__dapr::${EXPECTED_DAPR}" "express::${EXPECTED_EXPRESS}" "node done # --- Skip when the artifacts are already materialised -------------------------------------------- -marker_jar="${IVY_LOCAL}/dapr__dapr_sjs1_3/${EXPECTED_DAPR}/jars/dapr__dapr_sjs1_3.jar" -if [[ -f "${marker_jar}" ]]; then - echo "ScalablyTyped facades already present (${marker_jar}); nothing to do." +# 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 diff --git a/scripts/js-integration-env.sh b/scripts/js-integration-env.sh index 42bc4e2..ea9084e 100755 --- a/scripts/js-integration-env.sh +++ b/scripts/js-integration-env.sh @@ -61,7 +61,23 @@ PID_FILE="$WORK_DIR/server.pid" LOG_FILE="$WORK_DIR/server.log" log() { echo "[js-integration-env] $*" >&2; } -die() { log "ERROR: $*"; exit 1; } + +# 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 diff --git a/scripts/test-js-integration.sh b/scripts/test-js-integration.sh index e1b996a..3da67b5 100755 --- a/scripts/test-js-integration.sh +++ b/scripts/test-js-integration.sh @@ -20,7 +20,7 @@ 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/scalajs-async-jspi notes). +# 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 diff --git a/src/js/internal/InvokeCapabilityImpl.scala b/src/js/internal/InvokeCapabilityImpl.scala index 56476a4..2e01845 100644 --- a/src/js/internal/InvokeCapabilityImpl.scala +++ b/src/js/internal/InvokeCapabilityImpl.scala @@ -44,8 +44,13 @@ private object InvokeCapabilityImpl: 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. - val headers = ActorCapabilityImpl.baseHeaders(sidecar) + // 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()`). diff --git a/src/js/internal/StateCapabilityImpl.scala b/src/js/internal/StateCapabilityImpl.scala index b215a09..8db1281 100644 --- a/src/js/internal/StateCapabilityImpl.scala +++ b/src/js/internal/StateCapabilityImpl.scala @@ -246,9 +246,13 @@ private[internal] final class StateCapabilityImpl( // 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 and always present at runtime: the SDK substitutes `{results: []}` for - // an empty response body (implementation/Client/HTTPClient/state.js `query`). - response.results.toList.map { item => + // `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/wiki/dapr/dapr-js-sdk.md b/wiki/dapr/dapr-js-sdk.md index 014ca2f..bd8bb6d 100644 --- a/wiki/dapr/dapr-js-sdk.md +++ b/wiki/dapr/dapr-js-sdk.md @@ -1,8 +1,8 @@ # 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 +> 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-11 +> Updated: 2026-06-12 ## Overview @@ -131,6 +131,10 @@ If you hand-implement the AsyncGenerator protocol (dapr4s's `WorkflowCoroutine`) - **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`.) @@ -143,6 +147,7 @@ Content type is **inferred from the JS value** unless overridden: `Object`/`Arra - 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) @@ -151,7 +156,7 @@ Content type is **inferred from the JS value** unless overridden: `Object`/`Arra - **Conversation**: completely absent. - **Client-side streaming pub/sub subscriptions**: absent (subscribe only via `DaprServer`). -dapr4s throws `UnsupportedOperationException` from these capabilities on JS (or implements them with raw HTTP). +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) @@ -164,6 +169,7 @@ dapr4s throws `UnsupportedOperationException` from these capabilities on JS (or ## 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 diff --git a/wiki/index.md b/wiki/index.md index 894334e..bbd5bdb 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -56,7 +56,6 @@ Core Scala 3 language features for safe library design: opaque types, context fu | [Opaque Types](scala3-language/opaque-types.md) | Zero-cost type abstraction for wrapping Java/primitive types; companion object smart constructors, type bounds, extension methods | 2026-05-01 | | [Context Functions and Capability Passing](scala3-language/context-functions-capability-passing.md) | `A ?=> B` syntax, automatic expansion, capability threading, DSL builder pattern, OxDispatcher, postcondition pattern | 2026-05-01 | | [Given Instances and Using Clauses](scala3-language/given-using.md) | `given`/`using` syntax, conditional givens, alias givens, initialization semantics, summoning capabilities, Scala 2 migration | 2026-05-01 | -| [Scala CLI as Build Tool](scala3-language/scala-cli-build-tool.md) | Using directives, Java/Scala deps, experimental compiler flags (`-language:experimental.safe`/`captureChecking`), library project layout | 2026-05-01 | | [Java Interop and Safe Scala](scala3-language/java-interop-safe-scala.md) | `@assumeSafe`/`@rejectSafe`, wrapping Java SDK calls behind capability boundaries, trusted vs untrusted code zones, CC integration | 2026-05-01 | ## scala-effect-libraries @@ -97,7 +96,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), serialization/error rules, missing jobs/conversation | 2026-06-11 | +| [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 | @@ -157,6 +156,7 @@ Compiling Scala 3 (including capture-checked dapr4s) to JavaScript/WebAssembly | 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`, dep-directive platform leak + jvm-deps.scala/--exclude pattern, `::` dep syntax, publish --cross (_3 + _sjs1_3), scala-cli >= 1.13.0 floor, cwd-based npm resolution, GH Actions | 2026-06-11 | -| [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) | 2026-06-11 | +| [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 389efb7..651e34a 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1,5 +1,18 @@ # Wiki Log +## [2026-06-12] lint | 1 issue fixed + +- 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) diff --git a/wiki/scala-js/scala-js-async-jspi-wasm.md b/wiki/scala-js/scala-js-async-jspi-wasm.md index a8c36be..1b5c1e7 100644 --- a/wiki/scala-js/scala-js-async-jspi-wasm.md +++ b/wiki/scala-js/scala-js-async-jspi-wasm.md @@ -1,8 +1,8 @@ # 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 +> 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-11 +> Updated: 2026-06-12 ## Overview @@ -72,7 +72,7 @@ CI note: Node 23/24 flags must be argv flags on the node process (`NODE_OPTIONS` ## Field notes from the dapr4s port -Runtime-verified findings from implementing the dapr4s JS internal layer (`src/internal/js/`, scaladocs there are the canonical record). +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 @@ -96,6 +96,18 @@ Recipe (dapr4s `WorkflowCoroutine`, driving the Dapr JS SDK's orchestration exec - **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 diff --git a/wiki/scala-js/scala-js-cross-building-scala-cli.md b/wiki/scala-js/scala-js-cross-building-scala-cli.md index 307ef54..70f9a70 100644 --- a/wiki/scala-js/scala-js-cross-building-scala-cli.md +++ b/wiki/scala-js/scala-js-cross-building-scala-cli.md @@ -1,12 +1,12 @@ # 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 +> 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-11 +> 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 **dependency-directive platform leak** (no platform-scoped deps) and the **scala-cli >= 1.13.0 floor** for Scala.js 1.21 IR. +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 @@ -31,11 +31,21 @@ Grammar: `//> using platform (jvm|scala-js|js|scala-native|native)+`. This direc - **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). -## CRITICAL: dependency directives leak across platforms +## Platform-scoping dependencies: `target.platform` deps files (and the `test.dep` leak) -`using dep` / `using test.dep` directives written inside a `target.platform`-tagged file are **NOT scoped to that platform** — they apply to all platforms. Verified failure: `//> using test.dep com.dimafeng::testcontainers-scala-munit::0.43.6` inside a jvm-tagged test file made `scala-cli test --js .` fail resolving `testcontainers-scala-munit_sjs1_3` (404). There is **no platform-conditional dependency directive**. +**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 dapr4s pattern (`jvm-deps.scala` + `--exclude`):** put all JVM-only dep directives (`io.dapr:*`, testcontainers) into a dedicated `jvm-deps.scala` file at the repo root. Default invocations (`scala-cli compile/test .`) include it, so JVM workflows are unchanged; JS invocations pass `--exclude jvm-deps.scala`, keeping both resolution and the published `_sjs1_3` POM clean. (Alternative: pass JVM-only deps as CLI `--dep` flags on JVM invocations only.) Note that plain Java deps (single `:`) resolve fine on JS — but they'd still pollute the `_sjs1_3` POM, so they need the same treatment. Cost either way: single-shot `test --cross` can't be used when any platform needs excluded/CLI-only deps; run `test .` and `test --js . --exclude jvm-deps.scala` as two steps. +**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 @@ -55,7 +65,7 @@ scala-cli --power publish --cross . → 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, to combine with `--exclude jvm-deps.scala`). +- 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. @@ -79,6 +89,7 @@ Canonical job: `actions/checkout` (with `fetch-depth: 0` for `git:dynver`) + `co ## 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 +- [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..9c37262 --- /dev/null +++ b/wiki/scala-js/scalablytyped-with-scala-cli.md @@ -0,0 +1,65 @@ +# 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 +``` + +Then depend on the printed coordinates: `//> using dep "org.scalablytyped::dapr__dapr::3.18.0-d1e27c"` (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)). + +## 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 + +`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 (dapr4s's `dapr4s_sjs1_3` does), downstream users cannot resolve them from any remote repository. Options: + +1. **Ship the generation recipe** (dapr4s's 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. +2. 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). +3. 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) From d39e34392b21c3a9c3c88fca321a151d4fc98cfa Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Fri, 12 Jun 2026 23:52:11 +0200 Subject: [PATCH 11/17] refactor: self-contained _sjs1_3 artifact + JVM/JS coverage parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the Phase-3 review follow-ups: - Move the Scala.js-only deps/settings out of project.scala into js-deps.scala (scala-java-time, jsEsVersionStr "es2017") and add the symmetric empty js-test-deps.test.scala placeholder, so project.scala holds cross deps only. - Make the published dapr4s_sjs1_3 self-contained. The ScalablyTyped facades are generated into a dapr4s-specific `dapr4styped.*` package (renamed from the default `typings.*` so they can never collide with a consumer's own ST output), declared `compileOnly` so they never enter the POM, and embedded into the jar at publish time via scripts/embed-st-facades.sh + `--resource-dirs`. Verified by a local publish: the POM references only Maven Central, and the jar carries all 12,119 dapr4styped/*.sjsir — consumers need no ST regeneration. - Close the two integration-test coverage gaps so every JS-supported capability is exercised against a live sidecar on BOTH platforms: add the JVM ConfigurationCapabilityServerTest (configuration.redis, the twin of the JS configuration suite) and the JS CryptoJsIntegrationTest (crypto.dapr.localstorage with a per-run RSA key, the twin of the JVM crypto suite). jobs/conversation remain JS-absent at compile time (not untested); bindings is the lone symmetric gap (derivation + unit only). The in-code (JVM, testcontainers-dapr) vs YAML (JS) component-config mechanism — and why there is no scripts/jvm-it/components/ — is documented under "Integration-test coverage parity" in docs/DESIGN.md. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 20 ++-- .gitignore | 4 + AGENTS.md | 40 +++++--- README.md | 30 +++--- docs/DESIGN.md | 43 ++++++--- js-deps.scala | 62 +++++++++---- js-test-deps.test.scala | 8 ++ project.scala | 18 ++-- scripts/embed-st-facades.sh | 78 ++++++++++++++++ scripts/generate-st-facades.sh | 33 +++++-- scripts/js-integration-env.sh | 24 ++++- scripts/js-it/components/cryptostore.yaml | 14 +++ src/js/Dapr.scala | 4 +- src/js/internal/ActorCapabilityImpl.scala | 4 +- .../ConfigurationCapabilityImpl.scala | 6 +- src/js/internal/CryptoCapabilityImpl.scala | 10 +- src/js/internal/DaprAppServer.scala | 18 ++-- src/js/internal/DaprCapabilityImpl.scala | 6 +- src/js/internal/HttpActorContext.scala | 4 +- src/js/internal/InvokeCapabilityImpl.scala | 10 +- src/js/internal/JsInterop.scala | 2 +- src/js/internal/PublishCapabilityImpl.scala | 10 +- src/js/internal/StateCapabilityImpl.scala | 30 +++--- src/js/internal/WorkflowCapabilityImpl.scala | 6 +- src/js/internal/WorkflowContextImpl.scala | 6 +- src/js/internal/WorkflowCoroutine.scala | 4 +- src/js/internal/WorkflowHost.scala | 8 +- src/js/internal/facade/ExpressModule.scala | 8 +- .../integration/CryptoJsIntegrationTest.scala | 44 +++++++++ test/js/integration/JsItEnv.scala | 2 + .../ConfigurationCapabilityServerTest.scala | 91 +++++++++++++++++++ wiki/log.md | 8 +- wiki/scala-js/scalablytyped-with-scala-cli.md | 22 +++-- 33 files changed, 515 insertions(+), 162 deletions(-) create mode 100644 js-test-deps.test.scala create mode 100755 scripts/embed-st-facades.sh create mode 100644 scripts/js-it/components/cryptostore.yaml create mode 100644 test/js/integration/CryptoJsIntegrationTest.scala create mode 100644 test/jvm/integration/ConfigurationCapabilityServerTest.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7c8926..0e11589 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,8 +115,10 @@ jobs: - uses: VirtusLab/scala-cli-setup@v1 with: power: true - # The Scala.js publish resolves the ScalablyTyped facade deps (js-deps.scala), - # so the artifacts must exist in ~/.ivy2/local here too. + # 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 @@ -135,12 +137,16 @@ jobs: PUBLISH_PASSWORD: ${{ secrets.PUBLISH_PASSWORD }} PUBLISH_SECRET_KEY: ${{ secrets.PUBLISH_SECRET_KEY }} PUBLISH_SECRET_KEY_PASSWORD: ${{ secrets.PUBLISH_SECRET_KEY_PASSWORD }} - # Publishes dapr4s_sjs1_3. Its POM references the org.scalablytyped facade - # coordinates, which are NOT on Maven Central — downstream Scala.js users must run - # scripts/generate-st-facades.sh (same digests, deterministic) before linking; see - # README "Scala.js" and js-deps.scala. + # 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 . + run: scala-cli publish --js . --resource-dirs .scala-build/st-embed env: PUBLISH_USER: ${{ secrets.PUBLISH_USER }} PUBLISH_PASSWORD: ${{ secrets.PUBLISH_PASSWORD }} diff --git a/.gitignore b/.gitignore index 97aac30..1380606 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ node_modules/ # removes it after every run, ignored here as a second line of defence — scala-cli would # otherwise compile it as project sources. out/ + +# RSA key the JS crypto integration env generates fresh on every `up` +# (scripts/js-integration-env.sh) for the crypto.dapr.localstorage component. +scripts/js-it/keys/ diff --git a/AGENTS.md b/AGENTS.md index 080ec21..3c093d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,9 @@ Both must stay in sync with the code at all times. `//> 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. + 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.*'`. @@ -193,10 +195,11 @@ platform walls behind the same boundary: 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** (`typings.daprDapr`, `typings.expressServeStaticCore`, - `typings.node`, ... — see js-deps.scala), plus the single surviving hand-written shim in + **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`, - `typings.*` type, or other JS interop type may leak into the public API. Two deliberate + `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 @@ -239,20 +242,33 @@ the `_sjs1_3` artifact carries no jobs/conversation API at all. (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 `typings.*` jars in `~/.ivy2/local` - (run it once per machine; idempotent, fast skip when the jars exist). `js-deps.scala` pins the - resulting `org.scalablytyped::::-` coordinates; 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`) is THE pin: changing + 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 `typings.*` directly. + 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 @@ -264,7 +280,7 @@ the `_sjs1_3` artifact carries no jobs/conversation API at all. 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 `typings.daprDapr.mod.*` root re-exports, and where none exists (e.g. + **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; diff --git a/README.md b/README.md index c20ad8d..04caae0 100644 --- a/README.md +++ b/README.md @@ -107,21 +107,21 @@ def main(args: Array[String]): Unit = }: Unit ``` -### Scala.js facades: a one-time local generation step - -The `dapr4s_sjs1_3` POM references ScalablyTyped-generated facades of `@dapr/dapr` -(`org.scalablytyped::dapr__dapr` and friends, see `js-deps.scala`). These are **not on Maven -Central** — they live only in the local ivy repository (`~/.ivy2/local`), which scala-cli resolves -out of the box. Before your first build against dapr4s JS (and in CI), materialise them locally: - -1. get this repository's `package.json` + `package-lock.json` + `scripts/generate-st-facades.sh` - (all committed here — the converter inputs are `@dapr/dapr`, `@types/express`, `@types/node`, - with `typescript` needed by the converter itself), -2. run `npm ci`, then `scripts/generate-st-facades.sh`. - -The facade digests are deterministic in (package-lock.json, converter version, converter flags), -so the script reproduces exactly the coordinates the published POM references. It is idempotent -and skips instantly when the jars already exist. +### 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 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1933859..3d78c21 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -577,14 +577,16 @@ dapr4s/ ├── 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-generated facade coordinates -│ # (org.scalablytyped::dapr__dapr/express/node, resolved from ~/.ivy2/local) +├── 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 @@ -642,7 +644,7 @@ dapr4s/ │ ├── 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 typings.* facades (see Scala.js platform section) +│ │ # 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) @@ -674,16 +676,33 @@ dapr4s/ │ │ # JvmModelsTest, JvmServerRouteDerivationTest │ ├── integration/ # Docker/testcontainers suites: TestDaprApp + DaprTestContainer harnesses, │ │ # per-capability *CapabilityServerTest (State/Publish/Secrets/Lock/Actor/ - │ │ # Invoke/Workflow/Jobs/Crypto/Conversation), State/PubSub/Invoke/Secrets/ - │ │ # OrderService/InventoryService/EndToEnd IntegrationTests + │ │ # Invoke/Configuration/Workflow/Jobs/Crypto/Conversation), State/PubSub/Invoke/ + │ │ # Secrets/OrderService/InventoryService/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 suites against a live sidecar (see Scala.js platform section): - # State/PubSub/Invoke/Secrets/Configuration/Lock/Actor/Workflow + # 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`. The two +platforms drive the sidecar differently — the JVM via [testcontainers-dapr](https://github.com/diagridio/testcontainers-dapr), +whose idiomatic API declares components programmatically (`DaprContainer.withComponent(Component(name, type, version, +metadata))` inside each suite's `startContainers()`); the JS layer has no such library, so `scripts/js-integration-env.sh` +drives raw `daprd` under Docker with on-disk component YAMLs under `scripts/js-it/components/` (which is exactly what +testcontainers-dapr writes for you under the hood on the JVM). That is why there is no `scripts/jvm-it/components/` +directory: the JVM never needs component files on disk. The two stay equivalent in *content* — same component types, same +Redis `value||version` seeding for configuration, same crypto key material — rather than sharing one source, which would +mean abandoning the JVM's programmatic API or hand-rolling a YAML loader. + +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 @@ -905,13 +924,15 @@ Registration uses `registerWorkflowWithName`/`registerActivityWithName` with the ### 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`) 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 `typings.*` facade jars published to the **local** ivy repository (`~/.ivy2/local/org.scalablytyped/...`), which `js-deps.scala` pins and scala-cli resolves with zero configuration. Generated code is **never committed** and never published remotely. +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-d1e27c`), where the digest is deterministic in exactly (package-lock.json contents, converter version, converter flags). `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. +**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 `typings.*` directly. +**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` POM references the `org.scalablytyped` coordinates, which are **not on Maven Central**. Downstream Scala.js users must run the same generation (the script, `package.json` and `package-lock.json` all ship in this repository; the digests come out identical) to materialise the facades in their own `~/.ivy2/local` before resolving dapr4s JS. See the README's facade-generation recipe. +**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 @@ -921,7 +942,7 @@ A `//> using dep` directive in a file carrying a `//> using target.platform` dir |---|---|---| | `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 | +| `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. diff --git a/js-deps.scala b/js-deps.scala index f922071..c86b241 100644 --- a/js-deps.scala +++ b/js-deps.scala @@ -1,30 +1,54 @@ //> using target.platform "scala-js" -// Scala.js-only main-scope dependencies, the JS twin of jvm-deps.scala: the `target.platform` -// directive above scopes any `using dep` in this file to the Scala.js platform, so JVM builds -// never resolve them. +// 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 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. They are published into the LOCAL ivy -// repository (~/.ivy2/local/org.scalablytyped/...) — never to a remote repository and never -// committed — so every machine (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 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. `-d1e27c`) is the converter's deterministic -// digest of (package-lock.json contents, converter version, converter flags). To update: +// 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: the published dapr4s _sjs1_3 POM references these org.scalablytyped -// coordinates. They do not exist on Maven Central, so downstream Scala.js users must run the -// same generation (same package-lock.json, same converter version + flags — all shipped in -// this repository) to materialise them in their own ivy2Local before depending on dapr4s JS. +// 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" // -//> using dep "org.scalablytyped::dapr__dapr::3.18.0-d1e27c" -//> using dep "org.scalablytyped::express::4.17.21-bf7291" -//> using dep "org.scalablytyped::node::22.13.0-22253f" +// 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/project.scala b/project.scala index f89a58e..75fb7cd 100644 --- a/project.scala +++ b/project.scala @@ -1,7 +1,6 @@ //> using scala "3.10.0-RC1-bin-20260607-dec42ae-NIGHTLY" //> using platform "jvm" "scala-js" //> using jvm "zulu:25.0.3" -//> using jsEsVersionStr "es2017" //> using options "-language:experimental.captureChecking" //> using options "-language:experimental.pureFunctions" //> using options "-Ycc-verbose" @@ -14,18 +13,15 @@ // // Platforms: "jvm" is listed first, so plain `scala-cli compile/test .` builds the JVM // platform; select Scala.js with `--js` (no extra flags needed). -// jsEsVersionStr es2017 is required by js.async/js.await (used by the JS internal layer). // -// Platform-specific dependencies live in dedicated files, scoped by a `target.platform` -// directive (a `using dep` in a platform-tagged file applies only to that platform): -// - 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 main-scope deps (facades land here) +// 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). -// -// scala-java-time provides java.time on Scala.js (java.time.Instant is part of the public -// WorkflowSnapshot/Models API); on the JVM it is a thin shim over the JDK and harmless. -//> using dep "io.github.cquiroz::scala-java-time::2.6.0" //> using test.dep "org.scalameta::munit::1.3.0" //> using test.dep "com.lihaoyi::upickle::3.3.1" //> using publish.organization "com.github.sideeffffect" 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 index 7d8a029..6ed0e18 100755 --- a/scripts/generate-st-facades.sh +++ b/scripts/generate-st-facades.sh @@ -4,14 +4,17 @@ # 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; 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. +# 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). +# * 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 @@ -20,11 +23,22 @@ 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-d1e27c" -EXPECTED_EXPRESS="4.17.21-bf7291" -EXPECTED_NODE="22.13.0-22253f" +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" @@ -70,10 +84,11 @@ fi # 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})..." +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}") + --scala "${SCALA_VERSION}" --scalajs "${SCALAJS_VERSION}" -s "${STDLIB}" \ + --outputPackage "${OUTPUT_PACKAGE}") # --- Verify the expected digests came out -------------------------------------------------------- status=0 diff --git a/scripts/js-integration-env.sh b/scripts/js-integration-env.sh index ea9084e..4c0bcf3 100755 --- a/scripts/js-integration-env.sh +++ b/scripts/js-integration-env.sh @@ -29,8 +29,11 @@ # # daprd 1.17 workflows REQUIRE the scheduler service (the workflow engine schedules its # reminders there); actors require placement. Components: state.redis (actorStateStore=true), -# pubsub.redis, lock.redis, configuration.redis, secretstores.local.file — all under -# scripts/js-it/, mounted into the daprd container at /dapr4s-js-it. +# pubsub.redis, lock.redis, configuration.redis, secretstores.local.file, +# crypto.dapr.localstorage — all under scripts/js-it/, mounted into the daprd container at +# /dapr4s-js-it. The crypto store reads an RSA key from /dapr4s-js-it/keys, generated fresh on +# every `up` below (the keys/ dir is git-ignored — the JS twin of the per-test key +# CryptoCapabilityServerTest writes on the JVM). set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -110,6 +113,7 @@ down() { # 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 "$ROOT/scripts/js-it/keys" # the per-run crypto key (regenerated by up) } up() { @@ -124,6 +128,22 @@ up() { "$SCALA_CLI" --power package --test --js --js-emit-wasm --js-module-kind es "$ROOT" \ --main-class dapr4s.test.integration.jsTestServerMain -o "$DIST_DIR" -f + # -- 1b. Generate the RSA key the crypto.dapr.localstorage component loads. Fresh per run, into + # the git-ignored scripts/js-it/keys/ dir (mounted read-only into daprd at + # /dapr4s-js-it/keys). PKCS#8 PEM ("BEGIN PRIVATE KEY"), matching the key + # CryptoCapabilityServerTest generates via java.security.KeyPairGenerator on the JVM. + # World-readable (0644 file, 0755 dir): daprd runs as a non-root user in the container + # and otherwise fails the component with "permission denied". + log "generating crypto RSA key -> scripts/js-it/keys/rsa-key" + command -v openssl >/dev/null || die "openssl is required to generate the crypto test key" + local keys_dir="$ROOT/scripts/js-it/keys" + rm -rf "$keys_dir" + mkdir -p "$keys_dir" + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$keys_dir/rsa-key" 2>/dev/null \ + || die "openssl failed to generate the RSA key" + chmod 755 "$keys_dir" + chmod 644 "$keys_dir/rsa-key" + # -- 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 diff --git a/scripts/js-it/components/cryptostore.yaml b/scripts/js-it/components/cryptostore.yaml new file mode 100644 index 0000000..d2c906c --- /dev/null +++ b/scripts/js-it/components/cryptostore.yaml @@ -0,0 +1,14 @@ +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: scripts/js-it/ is mounted at + # /dapr4s-js-it (see scripts/js-integration-env.sh). The keys/ subdir holds the RSA key + # `rsa-key`, generated fresh on every `up` (git-ignored) — the JS twin of the key + # CryptoCapabilityServerTest writes to /keys on the JVM. + - name: path + value: /dapr4s-js-it/keys diff --git a/src/js/Dapr.scala b/src/js/Dapr.scala index 9308fc2..11124a5 100644 --- a/src/js/Dapr.scala +++ b/src/js/Dapr.scala @@ -3,7 +3,7 @@ package dapr4s import scala.scalajs.js import scala.util.control.NonFatal -import typings.daprDapr.mod.{DaprClient, DaprWorkflowClient} +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`). @@ -47,7 +47,7 @@ import typings.daprDapr.mod.{DaprClient, DaprWorkflowClient} * * 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 (`typings.daprDapr` — see js-deps.scala) are managed entirely here. + * clients it wraps (`dapr4styped.daprDapr` — see js-deps.scala) are managed entirely here. */ @scala.caps.assumeSafe class Dapr(config: DaprConfig = DaprConfig()): diff --git a/src/js/internal/ActorCapabilityImpl.scala b/src/js/internal/ActorCapabilityImpl.scala index ce5b7bc..61c055e 100644 --- a/src/js/internal/ActorCapabilityImpl.scala +++ b/src/js/internal/ActorCapabilityImpl.scala @@ -3,8 +3,8 @@ package dapr4s.internal import dapr4s.* import scala.scalajs.js -import typings.node.globalsMod.global as NodeGlobals -import typings.undiciTypes.fetchMod.RequestInit +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`. diff --git a/src/js/internal/ConfigurationCapabilityImpl.scala b/src/js/internal/ConfigurationCapabilityImpl.scala index 0c75b33..cdd387d 100644 --- a/src/js/internal/ConfigurationCapabilityImpl.scala +++ b/src/js/internal/ConfigurationCapabilityImpl.scala @@ -6,9 +6,9 @@ import scala.scalajs.js import scala.scalajs.js.JSConverters.* import scala.util.control.NonFatal import JsInterop.* -import typings.daprDapr.typesConfigurationConfigurationItemMod.ConfigurationItem as SdkConfigurationItem -import typings.daprDapr.typesConfigurationSubscribeConfigurationCallbackMod.SubscribeConfigurationCallback -import typings.daprDapr.typesConfigurationSubscribeConfigurationResponseMod.SubscribeConfigurationResponse +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( diff --git a/src/js/internal/CryptoCapabilityImpl.scala b/src/js/internal/CryptoCapabilityImpl.scala index eda0f46..e8dfb00 100644 --- a/src/js/internal/CryptoCapabilityImpl.scala +++ b/src/js/internal/CryptoCapabilityImpl.scala @@ -5,9 +5,9 @@ import dapr4s.* import scala.collection.immutable.ArraySeq import scala.scalajs.js import scala.scalajs.js.typedarray.{Int8Array, Uint8Array} -import typings.daprDapr.typesCryptoRequestsMod.{DecryptRequest, EncryptRequest} -import typings.node.bufferMod.global.Buffer -import typings.std.ArrayBufferLike +import dapr4styped.daprDapr.typesCryptoRequestsMod.{DecryptRequest, EncryptRequest} +import dapr4styped.node.bufferMod.global.Buffer +import dapr4styped.std.ArrayBufferLike @scala.caps.assumeSafe private[internal] final class CryptoCapabilityImpl( @@ -41,7 +41,7 @@ private[internal] final class CryptoCapabilityImpl( @scala.caps.assumeSafe private object CryptoCapabilityImpl: - import typings.daprDapr.daprDaprStrings + import dapr4styped.daprDapr.daprDaprStrings /** The SDK's `keyWrapAlgorithm` type: ScalablyTyped's rendering of the TS string-literal union on `EncryptRequest` * (`types/crypto/Requests.ts`). @@ -78,7 +78,7 @@ private object CryptoCapabilityImpl: // 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 typings.std.Uint8Array, a structural re-typing of the ECMAScript class that + // 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 diff --git a/src/js/internal/DaprAppServer.scala b/src/js/internal/DaprAppServer.scala index a38afcd..04ce384 100644 --- a/src/js/internal/DaprAppServer.scala +++ b/src/js/internal/DaprAppServer.scala @@ -8,13 +8,13 @@ import scala.jdk.CollectionConverters.* import scala.scalajs.js import scala.util.control.NonFatal import org.scalablytyped.runtime.Instantiable1 -import typings.expressServeStaticCore.mod.{Express, Handler, ParamsDictionary, Request, Response} -import typings.node.globalsMod.global as NodeGlobals -import typings.node.httpMod.{IncomingMessage, Server, ServerResponse} -import typings.node.nodeColonnetMod.Socket -import typings.node.processMod.global.NodeJS.Signals -import typings.qs.mod.ParsedQs -import typings.std.Record +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. @@ -371,7 +371,7 @@ private[dapr4s] final class DaprAppServer(app: DaprApp): // 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( - typings.node.nodeStrings.error, + dapr4styped.node.nodeStrings.error, (err: js.Error) => { reject(err) () @@ -452,7 +452,7 @@ private object DaprAppServer: * `server.stop(grace)` has no error channel either). */ private def closeServer(server: HttpServer)(onClosed: () => Unit): Unit = - server.asInstanceOf[typings.node.netMod.Server].close((_: js.UndefOr[js.Error]) => onClosed()): Unit + server.asInstanceOf[dapr4styped.node.netMod.Server].close((_: js.UndefOr[js.Error]) => onClosed()): Unit // ------------------------------------------------------------------------- // Per-request async entry diff --git a/src/js/internal/DaprCapabilityImpl.scala b/src/js/internal/DaprCapabilityImpl.scala index bcd76d1..7c50dbb 100644 --- a/src/js/internal/DaprCapabilityImpl.scala +++ b/src/js/internal/DaprCapabilityImpl.scala @@ -3,8 +3,8 @@ package dapr4s.internal import dapr4s.* import java.net.URI -import typings.daprDapr.anon.{PartialDaprClientOptions, PartialWorkflowClientOpti} -import typings.daprDapr.mod.{CommunicationProtocolEnum, DaprClient, DaprWorkflowClient} +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 @@ -35,7 +35,7 @@ private[dapr4s] final class LazyClientRef[A]: * of the JVM `DaprCapabilityImpl`. * * All interaction with the JS SDK is confined to this file and the individual `*CapabilityImpl` classes, through the - * ScalablyTyped-generated `typings.daprDapr` facades (see js-deps.scala). No JS types are visible in the public API. + * 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 diff --git a/src/js/internal/HttpActorContext.scala b/src/js/internal/HttpActorContext.scala index 1f8271e..2350288 100644 --- a/src/js/internal/HttpActorContext.scala +++ b/src/js/internal/HttpActorContext.scala @@ -4,8 +4,8 @@ package dapr4s.internal import dapr4s.* import scala.concurrent.duration.FiniteDuration import scala.scalajs.js -import typings.node.globalsMod.global as NodeGlobals -import typings.undiciTypes.fetchMod.RequestInit +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 diff --git a/src/js/internal/InvokeCapabilityImpl.scala b/src/js/internal/InvokeCapabilityImpl.scala index 2e01845..21fb7be 100644 --- a/src/js/internal/InvokeCapabilityImpl.scala +++ b/src/js/internal/InvokeCapabilityImpl.scala @@ -9,11 +9,11 @@ import JsInterop.* // (`@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 typings.daprDapr.enumHttpMethodDotenumMod.HttpMethod as SdkHttpMethod -import typings.daprDapr.mod.HttpMethod as SdkHttpMethods -import typings.daprDapr.typesInvokerOptionsDottypeMod.InvokerOptions -import typings.node.globalsMod.global as NodeGlobals -import typings.undiciTypes.fetchMod.RequestInit +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: diff --git a/src/js/internal/JsInterop.scala b/src/js/internal/JsInterop.scala index 69178d7..64b90d3 100644 --- a/src/js/internal/JsInterop.scala +++ b/src/js/internal/JsInterop.scala @@ -28,7 +28,7 @@ private[internal] object JsInterop: * `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 `typings.*` API, i.e. produced by JavaScript code, so it IS a + * 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. */ diff --git a/src/js/internal/PublishCapabilityImpl.scala b/src/js/internal/PublishCapabilityImpl.scala index 89d0bae..b9cf3a4 100644 --- a/src/js/internal/PublishCapabilityImpl.scala +++ b/src/js/internal/PublishCapabilityImpl.scala @@ -5,14 +5,14 @@ import dapr4s.* import scala.scalajs.js import scala.scalajs.js.JSConverters.* import JsInterop.* -import typings.daprDapr.typesPubsubPubSubBulkPublishMessageDottypeMod.{ +import dapr4styped.daprDapr.typesPubsubPubSubBulkPublishMessageDottypeMod.{ PubSubBulkPublishMessage, PubSubBulkPublishMessageExplicit, } -import typings.daprDapr.typesPubsubPubSubPublishOptionsDottypeMod.PubSubPublishOptions -import typings.daprDapr.typesPubsubPubSubPublishResponseDottypeMod.PubSubPublishResponseType -import typings.node.globalsMod.global as NodeGlobals -import typings.undiciTypes.fetchMod.RequestInit +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( diff --git a/src/js/internal/StateCapabilityImpl.scala b/src/js/internal/StateCapabilityImpl.scala index 8db1281..f6a81e6 100644 --- a/src/js/internal/StateCapabilityImpl.scala +++ b/src/js/internal/StateCapabilityImpl.scala @@ -5,23 +5,23 @@ import dapr4s.* import scala.scalajs.js import scala.scalajs.js.JSConverters.* import JsInterop.* -import typings.daprDapr.anon.{PartialStateDeleteOptions, PartialStateGetOptions} +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 typings.daprDapr.enumStateConcurrencyDotenumMod.StateConcurrencyEnum -import typings.daprDapr.enumStateConsistencyDotenumMod.StateConsistencyEnum -import typings.daprDapr.mod.{StateConcurrencyEnum as SdkConcurrency, StateConsistencyEnum as SdkConsistency} -import typings.daprDapr.typesKeyValuePairDottypeMod.KeyValuePairType -import typings.daprDapr.typesOperationDottypeMod.OperationType -import typings.daprDapr.typesRequestDottypeMod.IRequest -import typings.daprDapr.typesStateStateOptionsDottypeMod.IStateOptions -import typings.daprDapr.typesStateStateQueryDottypeMod.StateQueryType -import typings.daprDapr.typesStateStateSaveOptionsDottypeMod.StateSaveOptions -import typings.daprDapr.typesStateStateSaveResponseTypeMod.StateSaveResponseType -import typings.node.globalsMod.global as NodeGlobals -import typings.undiciTypes.fetchMod.RequestInit +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: @@ -69,8 +69,8 @@ private object StateCapabilityImpl: * 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): typings.daprDapr.typesEtagDottypeMod.IEtag = - etag.value.asInstanceOf[typings.daprDapr.typesEtagDottypeMod.IEtag] + private def toJsEtag(etag: ETag): dapr4styped.daprDapr.typesEtagDottypeMod.IEtag = + etag.value.asInstanceOf[dapr4styped.daprDapr.typesEtagDottypeMod.IEtag] private def toJsOp(op: StateOp): OperationType = op match diff --git a/src/js/internal/WorkflowCapabilityImpl.scala b/src/js/internal/WorkflowCapabilityImpl.scala index 5fbeddf..81114ad 100644 --- a/src/js/internal/WorkflowCapabilityImpl.scala +++ b/src/js/internal/WorkflowCapabilityImpl.scala @@ -8,9 +8,9 @@ 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 typings.daprDapr.mod.{DaprWorkflowClient, WorkflowRuntimeStatus as SdkStatuses} -import typings.daprDapr.workflowClientWorkflowStateMod.WorkflowState -import typings.daprDapr.workflowRuntimeWorkflowRuntimeStatusMod.WorkflowRuntimeStatus +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( diff --git a/src/js/internal/WorkflowContextImpl.scala b/src/js/internal/WorkflowContextImpl.scala index e573f1f..d71b145 100644 --- a/src/js/internal/WorkflowContextImpl.scala +++ b/src/js/internal/WorkflowContextImpl.scala @@ -5,9 +5,9 @@ import dapr4s.* import scala.concurrent.duration.FiniteDuration import scala.scalajs.js import unsafeExceptions.canThrowAny -import typings.daprDapr.workflowInternalDurabletaskTaskTaskMod.Task as SdkTask -import typings.daprDapr.workflowRuntimeWorkflowContextMod.WorkflowContext as SdkWorkflowContext -import typings.node.cryptoMod +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. diff --git a/src/js/internal/WorkflowCoroutine.scala b/src/js/internal/WorkflowCoroutine.scala index a9d6ceb..748f34d 100644 --- a/src/js/internal/WorkflowCoroutine.scala +++ b/src/js/internal/WorkflowCoroutine.scala @@ -4,8 +4,8 @@ package dapr4s.internal import dapr4s.* import scala.scalajs.js import scala.scalajs.js.annotation.JSName -import typings.daprDapr.workflowInternalDurabletaskTaskTaskMod.Task as SdkTask -import typings.daprDapr.workflowRuntimeWorkflowContextMod.WorkflowContext as SdkWorkflowContext +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 diff --git a/src/js/internal/WorkflowHost.scala b/src/js/internal/WorkflowHost.scala index cf887d8..22978ad 100644 --- a/src/js/internal/WorkflowHost.scala +++ b/src/js/internal/WorkflowHost.scala @@ -3,10 +3,10 @@ package dapr4s.internal import dapr4s.* import scala.scalajs.js -import typings.daprDapr.mod.WorkflowRuntime -import typings.daprDapr.typesWorkflowActivityDottypeMod.TWorkflowActivity -import typings.daprDapr.typesWorkflowInputOutputDottypeMod.{TInput, TOutput} -import typings.daprDapr.typesWorkflowWorkflowDottypeMod.TWorkflow +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. diff --git a/src/js/internal/facade/ExpressModule.scala b/src/js/internal/facade/ExpressModule.scala index aecf7cd..f55a815 100644 --- a/src/js/internal/facade/ExpressModule.scala +++ b/src/js/internal/facade/ExpressModule.scala @@ -3,23 +3,23 @@ package dapr4s.internal.facade import scala.scalajs.js import scala.scalajs.js.annotation.JSImport -import typings.expressServeStaticCore.mod.{Express, Handler} +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 `typings.*` +// 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. `typings.express.mod.apply()` calls through the module root captured as +// 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. `typings.express.mod.text` lost its type to a converter limitation — +// 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. diff --git a/test/js/integration/CryptoJsIntegrationTest.scala b/test/js/integration/CryptoJsIntegrationTest.scala new file mode 100644 index 0000000..2e6fc9e --- /dev/null +++ b/test/js/integration/CryptoJsIntegrationTest.scala @@ -0,0 +1,44 @@ +//> using target.platform "scala-js" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import munit.FunSuite +import scala.concurrent.duration.{Duration, DurationInt} +import scala.scalajs.js +import unsafeExceptions.canThrowAny +import JsItEnv.* + +/** [[CryptoCapability]] against a real `crypto.dapr.localstorage` component, backed by an RSA key generated by + * `scripts/js-integration-env.sh up` into `scripts/js-it/keys/rsa-key` and mounted into the sidecar — the Scala.js + * twin of [[CryptoCapabilityServerTest]]. + * + * Crypto is gRPC-only in the JS SDK (the HTTP client throws `HTTPNotSupportedError`), so like + * [[ConfigurationJsIntegrationTest]] this suite exercises the lazily created gRPC-protocol client over the real alpha1 + * streaming wire API. + */ +@scala.caps.assumeSafe +class CryptoJsIntegrationTest extends FunSuite: + + override def munitTimeout: Duration = 120.seconds + + test("crypto: encryptString then decryptString round-trips the original text"): + js.async { + Dapr(clientConfig).run: + DaprCapability.crypto(CryptoStore) { + val plaintext = "the quick brown fox" + val cipher = CryptoCapability.encryptString(CryptoKey, plaintext, KeyWrapAlgorithm.Rsa) + assert(cipher.nonEmpty, "ciphertext should not be empty") + assertEquals(CryptoCapability.decryptString(cipher), plaintext) + } + }.toFuture + + test("crypto: encrypt then decrypt round-trips raw bytes"): + js.async { + Dapr(clientConfig).run: + DaprCapability.crypto(CryptoStore) { + val data = Charsets.encodeString("payload-bytes", Charsets.Utf8) + val cipher = CryptoCapability.encrypt(CryptoKey, data, KeyWrapAlgorithm.Rsa) + assertEquals(CryptoCapability.decrypt(cipher), data) + } + }.toFuture diff --git a/test/js/integration/JsItEnv.scala b/test/js/integration/JsItEnv.scala index 3aa36b3..a30756e 100644 --- a/test/js/integration/JsItEnv.scala +++ b/test/js/integration/JsItEnv.scala @@ -42,6 +42,8 @@ object JsItEnv: 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( diff --git a/test/jvm/integration/ConfigurationCapabilityServerTest.scala b/test/jvm/integration/ConfigurationCapabilityServerTest.scala new file mode 100644 index 0000000..26a2836 --- /dev/null +++ b/test/jvm/integration/ConfigurationCapabilityServerTest.scala @@ -0,0 +1,91 @@ +//> using target.platform "jvm" +package dapr4s.test.integration + +import dapr4s.* +import dapr4s.given +import io.dapr.testcontainers.{Component, DaprContainer} +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 + +/** Tests for [[ConfigurationCapability]] against a real `configuration.redis` component — the JVM twin of + * [[ConfigurationJsIntegrationTest]] (same keys, same `value||version` seeding, same assertions). + * + * Dapr has no in-memory configuration store, so this uses Redis on a shared Docker network (like + * [[InventoryServiceIntegrationTest]]'s `lock.redis`). Keys are seeded by `redis-cli MSET` inside the Redis container + * — the in-container equivalent of the JS harness's `docker exec ... redis-cli MSET`; Dapr's redis configuration store + * splits `value||version` into value + version. + */ +@scala.caps.assumeSafe +class ConfigurationCapabilityServerTest extends FunSuite with TestContainersForAll: + + type Containers = GenericContainer and DaprTestContainer + + private val Store = ConfigurationStoreName("configstore") + + 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() + + // Seed before daprd reads: Dapr's redis configuration store stores "value||version". + val seed = redis.container.execInContainer( + "redis-cli", + "MSET", + "dapr4s-cfg-a", + "alpha||v1", + "dapr4s-cfg-b", + "beta||v2", + ) + assertEquals(seed.getExitCode, 0, s"redis MSET failed: ${seed.getStderr}") + + val c = DaprTestContainer( + DaprContainer(DaprTestContainer.DefaultImage) + .withNetwork(network) + .withAppName("configuration-server-test") + .withAppPort(0) + .withComponent( + Component("configstore", "configuration.redis", "v1", java.util.Map.of("redisHost", "redis:6379")), + ) + .dependsOn(redis.container), + ) + c.start() + redis and c + + test("configuration: get returns the seeded items with values and versions"): + withContainers { case _ and c => + Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): + DaprCapability.configuration(Store) { + val keyA = ConfigurationKey("dapr4s-cfg-a") + val keyB = ConfigurationKey("dapr4s-cfg-b") + val items = ConfigurationCapability.get(Seq(keyA, keyB)) + val a = items.getOrElse(keyA, fail(s"missing $keyA in $items")) + val b = items.getOrElse(keyB, fail(s"missing $keyB in $items")) + assertEquals(a.value, ConfigurationValue("alpha")) + assertEquals(a.version, ConfigurationVersion("v1")) + assertEquals(b.value, ConfigurationValue("beta")) + assertEquals(b.version, ConfigurationVersion("v2")) + } + } + + test("configuration: get for an unknown key returns no item for it"): + withContainers { case _ and c => + Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): + DaprCapability.configuration(Store) { + val absent = ConfigurationKey("dapr4s-cfg-absent") + val items = ConfigurationCapability.get(Seq(absent)) + assertEquals(items.get(absent), None) + } + } diff --git a/wiki/log.md b/wiki/log.md index 651e34a..f96bbca 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1,6 +1,12 @@ # Wiki Log -## [2026-06-12] lint | 1 issue fixed +## [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 diff --git a/wiki/scala-js/scalablytyped-with-scala-cli.md b/wiki/scala-js/scalablytyped-with-scala-cli.md index 9c37262..cb66352 100644 --- a/wiki/scala-js/scalablytyped-with-scala-cli.md +++ b/wiki/scala-js/scalablytyped-with-scala-cli.md @@ -10,10 +10,10 @@ ScalablyTyped (ST) converts TypeScript type definitions into Scala.js facades. I ```bash cs launch "org.scalablytyped.converter:cli_3:1.0.0-beta45" -- \ - --scala 3.3.6 --scalajs 1.21.0 -s es2022 + --scala 3.3.6 --scalajs 1.21.0 -s es2022 --outputPackage dapr4styped ``` -Then depend on the printed coordinates: `//> using dep "org.scalablytyped::dapr__dapr::3.18.0-d1e27c"` (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)). +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) @@ -49,13 +49,21 @@ Updating a pinned npm version: bump `package.json`, `npm install`, rerun the con - **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 +## 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 (dapr4s's `dapr4s_sjs1_3` does), downstream users cannot resolve them from any remote repository. Options: +`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. **Ship the generation recipe** (dapr4s's 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. -2. 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). -3. Vendor the generated sources into your repo (rejected for dapr4s: hundreds of thousands of generated lines, unreviewable diffs). +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 From 899f7087d9698c8a1b8c43f706c0c7e6b88bdf08 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Sat, 13 Jun 2026 00:12:14 +0200 Subject: [PATCH 12/17] fix(js): re-add ScalablyTyped facade jars to the package link classpath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The facades are `compileOnly.dep` (js-deps.scala) so the ivy-local-only org.scalablytyped coordinates stay out of the published POM. compileOnly keeps them on the compile and TEST classpaths — so `scala-cli compile --js` and `scala-cli test --js` link fine (the JS unit leg, incl. CapabilityDerivationTest which reaches the gRPC client path, is green) — but NOT on the runtime classpath that `scala-cli package` links against. Building JsTestServer with `package --test --js-emit-wasm` therefore failed at link time with "Referring to non-existent class dapr4styped..." / "Cannot access module for non-module ...", which surfaced only now because this is the first CI build using the compileOnly facades (the previous green run predated the typings.* -> dapr4styped.* + compileOnly rework). Fix: scripts/st-link-jars.sh resolves the exact transitive org.scalablytyped jar set (the same one embed-st-facades.sh embeds at publish) and emits `--jar` flags; js-integration-env.sh passes them to the JsTestServer package step. The linker de-duplicates against the compileOnly deps (verified: no duplicate-class errors) and `--jar` never touches the POM. Reproduced the failure and the fix locally (package exit 1 -> 0). Documented in docs/DESIGN.md. Co-Authored-By: Claude Opus 4.8 --- docs/DESIGN.md | 3 ++- scripts/js-integration-env.sh | 9 +++++++ scripts/st-link-jars.sh | 51 +++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100755 scripts/st-link-jars.sh diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 3d78c21..289557e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -953,7 +953,7 @@ Plain `scala-cli compile|test|publish --js .` therefore never resolves the Java 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`. Eight munit suites (26 tests) under `test/js/integration/` — state, pub/sub, invoke, secrets, configuration, lock, actors, workflows — 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. +- **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: @@ -962,6 +962,7 @@ Harness specifics, each compensating for a verified toolchain gap: - **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 package link classpath** (`scripts/st-link-jars.sh`): the facades are `compileOnly.dep`, which keeps them on the compile and *test* classpaths (so `scala-cli compile`/`test --js` link fine) but off the *runtime* classpath that `scala-cli package` links against. Building `JsTestServer` with `package --test` therefore needs the facade `.sjsir` re-added — `js-integration-env.sh` resolves the exact transitive `org.scalablytyped` jar set (the same one `embed-st-facades.sh` embeds at publish) and passes it as `--jar` flags; the linker de-duplicates and the POM is unaffected. ### Known platform divergences diff --git a/scripts/js-integration-env.sh b/scripts/js-integration-env.sh index 4c0bcf3..343ce8f 100755 --- a/scripts/js-integration-env.sh +++ b/scripts/js-integration-env.sh @@ -123,9 +123,18 @@ up() { # 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. Generate the RSA key the crypto.dapr.localstorage component loads. Fresh per run, into 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 From e226231d64701c27ba1b5f5924113adb811f0a48 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Sat, 13 Jun 2026 00:22:57 +0200 Subject: [PATCH 13/17] fix(js): also add facade jars to the Wasm `test` link, not just `package` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit fixed the JsTestServer `package` link but the suite run (`scala-cli test --js-emit-wasm` via wasm-test.sh) hit the same "Referring to non-existent class dapr4styped…" link errors — from StateJsIntegrationTest / WorkflowJsIntegrationTest reaching IRequest / IEtag / OperationType / WorkflowRuntimeStatus. So it is the Wasm backend's link (both `package` and `test --js-emit-wasm`) that excludes compileOnly deps, not `package` specifically; the plain-JS unit `test` leg links without the facades, which is what misled the first fix. test-js-integration.sh now passes the same scripts/st-link-jars.sh `--jar` set to the Wasm test invocation. Verified locally: the suite link now succeeds (it proceeds to the run phase, failing only on the absent local sidecar/NODE hook, which CI provides). Also sanity-checked the new crypto suite's key material: `openssl genpkey -algorithm RSA` emits the PKCS#8 PEM crypto.dapr.localstorage expects. DESIGN.md note corrected to cover both Wasm link commands. Co-Authored-By: Claude Opus 4.8 --- docs/DESIGN.md | 2 +- scripts/test-js-integration.sh | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 289557e..d23c284 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -962,7 +962,7 @@ Harness specifics, each compensating for a verified toolchain gap: - **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 package link classpath** (`scripts/st-link-jars.sh`): the facades are `compileOnly.dep`, which keeps them on the compile and *test* classpaths (so `scala-cli compile`/`test --js` link fine) but off the *runtime* classpath that `scala-cli package` links against. Building `JsTestServer` with `package --test` therefore needs the facade `.sjsir` re-added — `js-integration-env.sh` resolves the exact transitive `org.scalablytyped` jar set (the same one `embed-st-facades.sh` embeds at publish) and passes it as `--jar` flags; the linker de-duplicates and the POM is unaffected. +- **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 diff --git a/scripts/test-js-integration.sh b/scripts/test-js-integration.sh index 3da67b5..77cf286 100755 --- a/scripts/test-js-integration.sh +++ b/scripts/test-js-integration.sh @@ -42,8 +42,15 @@ fi 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 From 0ad84d028bf09f70a2e39f2a4feabe450309ae0b Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Sat, 13 Jun 2026 01:19:16 +0200 Subject: [PATCH 14/17] test(it): unify Dapr component config into one shared set (JS side) Single source of truth scripts/it/components/*.yaml + scripts/it/secrets.json, rendered per topology by scripts/it/render-components.sh (only redisHost differs: JS host-network renders localhost:6391). The JS harness now assembles a per-run resource dir (rendered components + shared secrets.json + fresh RSA key) mounted into daprd at /dapr4s-it, replacing the JS-only scripts/js-it/components set. Backends unified toward 'redis everywhere': state/pubsub/lock/configuration = redis, secrets = local.file, crypto = localstorage. JS test seeds aligned (it-secret-a/b, dapr4s-it-cfg-a/b). JVM suites move onto the same shared set in a follow-up commit. Spec: docs/JVM-JS-PARITY.md. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 - docs/JVM-JS-PARITY.md | 89 +++++++++++++++++++ scripts/it/components/configstore.yaml | 14 +++ scripts/it/components/cryptostore.yaml | 13 +++ .../{js-it => it}/components/lockstore.yaml | 3 +- scripts/{js-it => it}/components/pubsub.yaml | 3 +- scripts/it/components/secretstore.yaml | 14 +++ .../{js-it => it}/components/statestore.yaml | 7 +- scripts/it/render-components.sh | 32 +++++++ scripts/it/secrets.json | 4 + scripts/js-integration-env.sh | 57 ++++++------ scripts/js-it/components/configstore.yaml | 12 --- scripts/js-it/components/cryptostore.yaml | 14 --- scripts/js-it/components/secretstore.yaml | 12 --- scripts/js-it/secrets.json | 4 - .../ConfigurationJsIntegrationTest.scala | 6 +- .../integration/CryptoJsIntegrationTest.scala | 4 +- test/js/integration/JsItEnv.scala | 2 +- .../SecretsJsIntegrationTest.scala | 16 ++-- 19 files changed, 220 insertions(+), 90 deletions(-) create mode 100644 docs/JVM-JS-PARITY.md create mode 100644 scripts/it/components/configstore.yaml create mode 100644 scripts/it/components/cryptostore.yaml rename scripts/{js-it => it}/components/lockstore.yaml (63%) rename scripts/{js-it => it}/components/pubsub.yaml (63%) create mode 100644 scripts/it/components/secretstore.yaml rename scripts/{js-it => it}/components/statestore.yaml (50%) create mode 100755 scripts/it/render-components.sh create mode 100644 scripts/it/secrets.json delete mode 100644 scripts/js-it/components/configstore.yaml delete mode 100644 scripts/js-it/components/cryptostore.yaml delete mode 100644 scripts/js-it/components/secretstore.yaml delete mode 100644 scripts/js-it/secrets.json diff --git a/.gitignore b/.gitignore index 1380606..97aac30 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,3 @@ node_modules/ # removes it after every run, ignored here as a second line of defence — scala-cli would # otherwise compile it as project sources. out/ - -# RSA key the JS crypto integration env generates fresh on every `up` -# (scripts/js-integration-env.sh) for the crypto.dapr.localstorage component. -scripts/js-it/keys/ diff --git a/docs/JVM-JS-PARITY.md b/docs/JVM-JS-PARITY.md new file mode 100644 index 0000000..5d3e0ee --- /dev/null +++ b/docs/JVM-JS-PARITY.md @@ -0,0 +1,89 @@ +# JVM ↔ Scala.js test & config parity + +Status: in progress (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. + +## 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) + +1. Canonical `scripts/it/components/*.yaml` + render step. (#10) +2. Point the JS harness at it. (#11) +3. Point the JVM suites at it; in-memory→redis, env→file. (#12) +4. Shared scenario traits + thin shells; close coverage/naming gaps. (#13) +5. Verify both platforms locally, update docs/wiki, push, keep PR #38 green. (#14) 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/js-it/components/lockstore.yaml b/scripts/it/components/lockstore.yaml similarity index 63% rename from scripts/js-it/components/lockstore.yaml rename to scripts/it/components/lockstore.yaml index 7b01931..4d45ee1 100644 --- a/scripts/js-it/components/lockstore.yaml +++ b/scripts/it/components/lockstore.yaml @@ -6,7 +6,8 @@ spec: type: lock.redis version: v1 metadata: + # See statestore.yaml for the ${DAPR4S_IT_REDIS_HOST} substitution. - name: redisHost - value: localhost:6391 + value: ${DAPR4S_IT_REDIS_HOST} - name: redisPassword value: "" diff --git a/scripts/js-it/components/pubsub.yaml b/scripts/it/components/pubsub.yaml similarity index 63% rename from scripts/js-it/components/pubsub.yaml rename to scripts/it/components/pubsub.yaml index 7348a4f..1746e19 100644 --- a/scripts/js-it/components/pubsub.yaml +++ b/scripts/it/components/pubsub.yaml @@ -6,7 +6,8 @@ spec: type: pubsub.redis version: v1 metadata: + # See statestore.yaml for the ${DAPR4S_IT_REDIS_HOST} substitution. - name: redisHost - value: localhost:6391 + 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..825f862 --- /dev/null +++ b/scripts/it/components/secretstore.yaml @@ -0,0 +1,14 @@ +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 + - name: nestedSeparator + value: ":" diff --git a/scripts/js-it/components/statestore.yaml b/scripts/it/components/statestore.yaml similarity index 50% rename from scripts/js-it/components/statestore.yaml rename to scripts/it/components/statestore.yaml index 7a64066..5f29e8d 100644 --- a/scripts/js-it/components/statestore.yaml +++ b/scripts/it/components/statestore.yaml @@ -6,10 +6,11 @@ spec: type: state.redis version: v1 metadata: - # The daprd container runs with --network host; redis publishes container port 6379 - # on host port 6391 (see scripts/js-integration-env.sh). + # ${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: localhost:6391 + value: ${DAPR4S_IT_REDIS_HOST} - name: redisPassword value: "" # The Counter actor and the workflow runtime both store their state here. 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 index 343ce8f..8c027f9 100755 --- a/scripts/js-integration-env.sh +++ b/scripts/js-integration-env.sh @@ -25,15 +25,16 @@ # 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 (see scripts/js-it/components/*.yaml). +# reaches it as localhost:6391. # # daprd 1.17 workflows REQUIRE the scheduler service (the workflow engine schedules its -# reminders there); actors require placement. Components: state.redis (actorStateStore=true), -# pubsub.redis, lock.redis, configuration.redis, secretstores.local.file, -# crypto.dapr.localstorage — all under scripts/js-it/, mounted into the daprd container at -# /dapr4s-js-it. The crypto store reads an RSA key from /dapr4s-js-it/keys, generated fresh on -# every `up` below (the keys/ dir is git-ignored — the JS twin of the per-test key -# CryptoCapabilityServerTest writes on the JVM). +# 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)" @@ -62,6 +63,9 @@ 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; } @@ -113,7 +117,7 @@ down() { # 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 "$ROOT/scripts/js-it/keys" # the per-run crypto key (regenerated by up) + rm -rf "$DAPR_DIR" # per-run rendered components + secrets + crypto key (regenerated by up) } up() { @@ -137,21 +141,24 @@ up() { "${st_link[@]}" \ --main-class dapr4s.test.integration.jsTestServerMain -o "$DIST_DIR" -f - # -- 1b. Generate the RSA key the crypto.dapr.localstorage component loads. Fresh per run, into - # the git-ignored scripts/js-it/keys/ dir (mounted read-only into daprd at - # /dapr4s-js-it/keys). PKCS#8 PEM ("BEGIN PRIVATE KEY"), matching the key - # CryptoCapabilityServerTest generates via java.security.KeyPairGenerator on the JVM. - # World-readable (0644 file, 0755 dir): daprd runs as a non-root user in the container - # and otherwise fails the component with "permission denied". - log "generating crypto RSA key -> scripts/js-it/keys/rsa-key" + # -- 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" - local keys_dir="$ROOT/scripts/js-it/keys" - rm -rf "$keys_dir" - mkdir -p "$keys_dir" - openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$keys_dir/rsa-key" 2>/dev/null \ + 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 755 "$keys_dir" - chmod 644 "$keys_dir/rsa-key" + chmod -R a+rX "$DAPR_DIR" # -- 2. Infrastructure containers. log "starting redis ($C_REDIS, host port $REDIS_PORT)" @@ -171,7 +178,7 @@ up() { log "starting daprd ($C_DAPRD, http $DAPR_HTTP_PORT / grpc $DAPR_GRPC_PORT)" docker run -d --name "$C_DAPRD" --network host \ - -v "$ROOT/scripts/js-it:/dapr4s-js-it:ro" \ + -v "$DAPR_DIR:/dapr4s-it:ro" \ "$DAPR_IMAGE_DAPRD" \ ./daprd \ --app-id "$APP_ID" \ @@ -183,7 +190,7 @@ up() { --metrics-port 9593 \ --placement-host-address "localhost:$PLACEMENT_PORT" \ --scheduler-host-address "localhost:$SCHEDULER_PORT" \ - --resources-path /dapr4s-js-it/components \ + --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 @@ -200,8 +207,8 @@ up() { # configuration store reads plain keys; "value||version" splits into value + version). log "seeding configuration keys" docker exec "$C_REDIS" redis-cli MSET \ - dapr4s-js-it-cfg-a "alpha||v1" \ - dapr4s-js-it-cfg-b "beta||v2" >/dev/null + dapr4s-it-cfg-a "alpha||v1" \ + dapr4s-it-cfg-b "beta||v2" >/dev/null log "environment is up" } diff --git a/scripts/js-it/components/configstore.yaml b/scripts/js-it/components/configstore.yaml deleted file mode 100644 index a91f52b..0000000 --- a/scripts/js-it/components/configstore.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: dapr.io/v1alpha1 -kind: Component -metadata: - name: configstore -spec: - type: configuration.redis - version: v1 - metadata: - - name: redisHost - value: localhost:6391 - - name: redisPassword - value: "" diff --git a/scripts/js-it/components/cryptostore.yaml b/scripts/js-it/components/cryptostore.yaml deleted file mode 100644 index d2c906c..0000000 --- a/scripts/js-it/components/cryptostore.yaml +++ /dev/null @@ -1,14 +0,0 @@ -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: scripts/js-it/ is mounted at - # /dapr4s-js-it (see scripts/js-integration-env.sh). The keys/ subdir holds the RSA key - # `rsa-key`, generated fresh on every `up` (git-ignored) — the JS twin of the key - # CryptoCapabilityServerTest writes to /keys on the JVM. - - name: path - value: /dapr4s-js-it/keys diff --git a/scripts/js-it/components/secretstore.yaml b/scripts/js-it/components/secretstore.yaml deleted file mode 100644 index 192afec..0000000 --- a/scripts/js-it/components/secretstore.yaml +++ /dev/null @@ -1,12 +0,0 @@ -apiVersion: dapr.io/v1alpha1 -kind: Component -metadata: - name: secretstore -spec: - type: secretstores.local.file - version: v1 - metadata: - # Path as seen INSIDE the daprd container: scripts/js-it/ is mounted at /dapr4s-js-it - # (see scripts/js-integration-env.sh). - - name: secretsFile - value: /dapr4s-js-it/secrets.json diff --git a/scripts/js-it/secrets.json b/scripts/js-it/secrets.json deleted file mode 100644 index a1fe194..0000000 --- a/scripts/js-it/secrets.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "js-it-secret": "s3cr3t-js", - "another-secret": "other-value" -} diff --git a/test/js/integration/ConfigurationJsIntegrationTest.scala b/test/js/integration/ConfigurationJsIntegrationTest.scala index b7cab36..561f903 100644 --- a/test/js/integration/ConfigurationJsIntegrationTest.scala +++ b/test/js/integration/ConfigurationJsIntegrationTest.scala @@ -23,8 +23,8 @@ class ConfigurationJsIntegrationTest extends FunSuite: js.async { Dapr(clientConfig).run: DaprCapability.configuration(ConfigStore) { - val keyA = ConfigurationKey("dapr4s-js-it-cfg-a") - val keyB = ConfigurationKey("dapr4s-js-it-cfg-b") + val keyA = ConfigurationKey("dapr4s-it-cfg-a") + val keyB = ConfigurationKey("dapr4s-it-cfg-b") val items = ConfigurationCapability.get(Seq(keyA, keyB)) val a = items.getOrElse(keyA, fail(s"missing $keyA in $items")) val b = items.getOrElse(keyB, fail(s"missing $keyB in $items")) @@ -39,7 +39,7 @@ class ConfigurationJsIntegrationTest extends FunSuite: js.async { Dapr(clientConfig).run: DaprCapability.configuration(ConfigStore) { - val absent = ConfigurationKey(s"dapr4s-js-it-absent-${uniqueId()}") + val absent = ConfigurationKey(s"dapr4s-it-absent-${uniqueId()}") val items = ConfigurationCapability.get(Seq(absent)) assertEquals(items.get(absent), None) } diff --git a/test/js/integration/CryptoJsIntegrationTest.scala b/test/js/integration/CryptoJsIntegrationTest.scala index 2e6fc9e..d8274b6 100644 --- a/test/js/integration/CryptoJsIntegrationTest.scala +++ b/test/js/integration/CryptoJsIntegrationTest.scala @@ -10,8 +10,8 @@ import unsafeExceptions.canThrowAny import JsItEnv.* /** [[CryptoCapability]] against a real `crypto.dapr.localstorage` component, backed by an RSA key generated by - * `scripts/js-integration-env.sh up` into `scripts/js-it/keys/rsa-key` and mounted into the sidecar — the Scala.js - * twin of [[CryptoCapabilityServerTest]]. + * `scripts/js-integration-env.sh up` into the shared resource dir's `keys/rsa-key` and mounted into the sidecar — the + * Scala.js twin of [[CryptoCapabilityServerTest]]. * * Crypto is gRPC-only in the JS SDK (the HTTP client throws `HTTPNotSupportedError`), so like * [[ConfigurationJsIntegrationTest]] this suite exercises the lazily created gRPC-protocol client over the real alpha1 diff --git a/test/js/integration/JsItEnv.scala b/test/js/integration/JsItEnv.scala index a30756e..a03440f 100644 --- a/test/js/integration/JsItEnv.scala +++ b/test/js/integration/JsItEnv.scala @@ -36,7 +36,7 @@ object JsItEnv: val AppPort: Int = 8391 val ServerAppId: AppId = AppId("js-it-server") - // Component names match scripts/js-it/components/*.yaml. + // 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") diff --git a/test/js/integration/SecretsJsIntegrationTest.scala b/test/js/integration/SecretsJsIntegrationTest.scala index 82a5a70..c690f77 100644 --- a/test/js/integration/SecretsJsIntegrationTest.scala +++ b/test/js/integration/SecretsJsIntegrationTest.scala @@ -9,8 +9,8 @@ import scala.scalajs.js import unsafeExceptions.canThrowAny import JsItEnv.* -/** [[SecretsCapability]] against a real `secretstores.local.file` component (seeded from `scripts/js-it/secrets.json`) - * — the Scala.js twin of [[SecretsCapabilityServerTest]]. +/** [[SecretsCapability]] against a real `secretstores.local.file` component (seeded from the shared + * `scripts/it/secrets.json`) — the Scala.js twin of [[SecretsCapabilityServerTest]]. * * A missing key THROWS rather than returning `None`: the local-file store answers 500, which rejects the SDK promise — * the documented behaviour on both platforms (`SecretsCapabilityImpl`: "a sidecar error REJECTS the promise and @@ -25,8 +25,8 @@ class SecretsJsIntegrationTest extends FunSuite: js.async { Dapr(clientConfig).run: DaprCapability.secrets(SecretStore) { - assertEquals(SecretsCapability.get(SecretKey("js-it-secret")), Some(SecretValue("s3cr3t-js"))) - assertEquals(SecretsCapability.get(SecretKey("another-secret")), Some(SecretValue("other-value"))) + assertEquals(SecretsCapability.get(SecretKey("it-secret-a")), Some(SecretValue("secret-value-alpha"))) + assertEquals(SecretsCapability.get(SecretKey("it-secret-b")), Some(SecretValue("secret-value-beta"))) } }.toFuture @@ -37,12 +37,12 @@ class SecretsJsIntegrationTest extends FunSuite: val bulk = SecretsCapability.getBulk() // The bulk response nests {secretName: {key: value}}; dapr4s flattens to "name/key" compound keys. assert( - bulk.exists { case (k, v) => k.value.contains("js-it-secret") && v.value == "s3cr3t-js" }, - s"expected js-it-secret in bulk result; got keys: ${bulk.keys.map(_.value).toList.sorted}", + bulk.exists { case (k, v) => k.value.contains("it-secret-a") && v.value == "secret-value-alpha" }, + s"expected it-secret-a in bulk result; got keys: ${bulk.keys.map(_.value).toList.sorted}", ) assert( - bulk.exists { case (k, v) => k.value.contains("another-secret") && v.value == "other-value" }, - "expected another-secret in bulk result", + bulk.exists { case (k, v) => k.value.contains("it-secret-b") && v.value == "secret-value-beta" }, + "expected it-secret-b in bulk result", ) } }.toFuture From 458ea7da588d3cbf7bb0683578bd59813abfe255 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Sat, 13 Jun 2026 01:32:02 +0200 Subject: [PATCH 15/17] test(it): shared scenarios + redis for direct-call capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify state/secrets/lock/crypto/configuration across platforms: each capability's calls+assertions live once as a shared scenario trait in test/shared/scenarios (self: munit.Assertions =>, shared API + given DaprCapability). JVM and JS suites are now thin shells that own only bring-up and the sync/Future boundary and call the same scenarios — equal coverage, one set of assertions. JVM: new SharedDaprItSuite mirrors the JS harness's single all-components sidecar — one daprd on a real Redis loading the canonical scripts/it/components set via DaprContainer.withComponent(Path), with shared secrets.json + a fresh RSA key mounted at /dapr4s-it (JvmItComponents renders with redisHost redis:6379). This moves JVM state in-memory->redis and secrets local.env->local.file, matching JS. Removed the now-redundant server-routed/direct JVM twins (State/Secrets/Lock/ Crypto/Configuration *CapabilityServerTest + *IntegrationTest); route dispatch is covered by the unit ServerRouteDerivationTest. ETag scenarios use a stale-but-real etag (Redis rejects non-numeric etags with 400 rather than a conflict). Verified locally against daprd 1.17 + redis: 20/20 across the 5 new JVM ItTest suites. JS twins compile; run on the live sidecar in CI. Co-Authored-By: Claude Opus 4.8 --- scripts/it/components/secretstore.yaml | 2 - .../ConfigurationJsIntegrationTest.scala | 41 +- .../integration/CryptoJsIntegrationTest.scala | 37 +- test/js/integration/JsItEnv.scala | 2 +- .../integration/LockJsIntegrationTest.scala | 43 +- .../SecretsJsIntegrationTest.scala | 51 +-- .../integration/StateJsIntegrationTest.scala | 125 +----- .../ConfigurationCapabilityServerTest.scala | 91 ---- .../jvm/integration/ConfigurationItTest.scala | 17 + .../CryptoCapabilityServerTest.scala | 76 ---- test/jvm/integration/CryptoItTest.scala | 18 + test/jvm/integration/JvmItComponents.scala | 80 ++++ .../LockCapabilityServerTest.scala | 208 --------- test/jvm/integration/LockItTest.scala | 19 + .../SecretsCapabilityServerTest.scala | 120 ----- .../integration/SecretsIntegrationTest.scala | 44 -- test/jvm/integration/SecretsItTest.scala | 19 + test/jvm/integration/SharedDaprItSuite.scala | 72 +++ .../StateCapabilityServerTest.scala | 411 ------------------ .../integration/StateIntegrationTest.scala | 116 ----- test/jvm/integration/StateItTest.scala | 31 ++ .../scenarios/ConfigurationScenarios.scala | 28 ++ test/shared/scenarios/CryptoScenarios.scala | 26 ++ test/shared/scenarios/ItNames.scala | 36 ++ test/shared/scenarios/LockScenarios.scala | 34 ++ test/shared/scenarios/SecretsScenarios.scala | 44 ++ test/shared/scenarios/StateScenarios.scala | 99 +++++ 27 files changed, 591 insertions(+), 1299 deletions(-) delete mode 100644 test/jvm/integration/ConfigurationCapabilityServerTest.scala create mode 100644 test/jvm/integration/ConfigurationItTest.scala delete mode 100644 test/jvm/integration/CryptoCapabilityServerTest.scala create mode 100644 test/jvm/integration/CryptoItTest.scala create mode 100644 test/jvm/integration/JvmItComponents.scala delete mode 100644 test/jvm/integration/LockCapabilityServerTest.scala create mode 100644 test/jvm/integration/LockItTest.scala delete mode 100644 test/jvm/integration/SecretsCapabilityServerTest.scala delete mode 100644 test/jvm/integration/SecretsIntegrationTest.scala create mode 100644 test/jvm/integration/SecretsItTest.scala create mode 100644 test/jvm/integration/SharedDaprItSuite.scala delete mode 100644 test/jvm/integration/StateCapabilityServerTest.scala delete mode 100644 test/jvm/integration/StateIntegrationTest.scala create mode 100644 test/jvm/integration/StateItTest.scala create mode 100644 test/shared/scenarios/ConfigurationScenarios.scala create mode 100644 test/shared/scenarios/CryptoScenarios.scala create mode 100644 test/shared/scenarios/ItNames.scala create mode 100644 test/shared/scenarios/LockScenarios.scala create mode 100644 test/shared/scenarios/SecretsScenarios.scala create mode 100644 test/shared/scenarios/StateScenarios.scala diff --git a/scripts/it/components/secretstore.yaml b/scripts/it/components/secretstore.yaml index 825f862..c19f235 100644 --- a/scripts/it/components/secretstore.yaml +++ b/scripts/it/components/secretstore.yaml @@ -10,5 +10,3 @@ spec: # /dapr4s-it/secrets.json by both harnesses. - name: secretsFile value: /dapr4s-it/secrets.json - - name: nestedSeparator - value: ":" diff --git a/test/js/integration/ConfigurationJsIntegrationTest.scala b/test/js/integration/ConfigurationJsIntegrationTest.scala index 561f903..defbd8b 100644 --- a/test/js/integration/ConfigurationJsIntegrationTest.scala +++ b/test/js/integration/ConfigurationJsIntegrationTest.scala @@ -4,43 +4,26 @@ 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.* -/** [[ConfigurationCapability]] against a real `configuration.redis` component. The keys are seeded by - * `scripts/js-integration-env.sh up` via `docker exec ... redis-cli MSET` (Dapr's redis configuration store splits - * `value||version` into value + version). Configuration is gRPC-only in the JS SDK, so this is also the suite that - * exercises the lazily created gRPC-protocol client end to end. +/** 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: +class ConfigurationJsIntegrationTest extends FunSuite, ConfigurationScenarios: override def munitTimeout: Duration = 120.seconds - test("configuration: get returns the seeded items with values and versions"): - js.async { - Dapr(clientConfig).run: - DaprCapability.configuration(ConfigStore) { - val keyA = ConfigurationKey("dapr4s-it-cfg-a") - val keyB = ConfigurationKey("dapr4s-it-cfg-b") - val items = ConfigurationCapability.get(Seq(keyA, keyB)) - val a = items.getOrElse(keyA, fail(s"missing $keyA in $items")) - val b = items.getOrElse(keyB, fail(s"missing $keyB in $items")) - assertEquals(a.value, ConfigurationValue("alpha")) - assertEquals(a.version, ConfigurationVersion("v1")) - assertEquals(b.value, ConfigurationValue("beta")) - assertEquals(b.version, ConfigurationVersion("v2")) - } - }.toFuture + private def run(body: DaprCapability ?=> Unit): Future[Unit] = + js.async(Dapr(clientConfig).run(body)).toFuture - test("configuration: get for an unknown key returns no item for it"): - js.async { - Dapr(clientConfig).run: - DaprCapability.configuration(ConfigStore) { - val absent = ConfigurationKey(s"dapr4s-it-absent-${uniqueId()}") - val items = ConfigurationCapability.get(Seq(absent)) - assertEquals(items.get(absent), None) - } - }.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 index d8274b6..888e5ae 100644 --- a/test/js/integration/CryptoJsIntegrationTest.scala +++ b/test/js/integration/CryptoJsIntegrationTest.scala @@ -4,41 +4,26 @@ 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.* -/** [[CryptoCapability]] against a real `crypto.dapr.localstorage` component, backed by an RSA key generated by - * `scripts/js-integration-env.sh up` into the shared resource dir's `keys/rsa-key` and mounted into the sidecar — the - * Scala.js twin of [[CryptoCapabilityServerTest]]. +/** 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 like - * [[ConfigurationJsIntegrationTest]] this suite exercises the lazily created gRPC-protocol client over the real alpha1 - * streaming wire API. + * 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: +class CryptoJsIntegrationTest extends FunSuite, CryptoScenarios: override def munitTimeout: Duration = 120.seconds - test("crypto: encryptString then decryptString round-trips the original text"): - js.async { - Dapr(clientConfig).run: - DaprCapability.crypto(CryptoStore) { - val plaintext = "the quick brown fox" - val cipher = CryptoCapability.encryptString(CryptoKey, plaintext, KeyWrapAlgorithm.Rsa) - assert(cipher.nonEmpty, "ciphertext should not be empty") - assertEquals(CryptoCapability.decryptString(cipher), plaintext) - } - }.toFuture + private def run(body: DaprCapability ?=> Unit): Future[Unit] = + js.async(Dapr(clientConfig).run(body)).toFuture - test("crypto: encrypt then decrypt round-trips raw bytes"): - js.async { - Dapr(clientConfig).run: - DaprCapability.crypto(CryptoStore) { - val data = Charsets.encodeString("payload-bytes", Charsets.Utf8) - val cipher = CryptoCapability.encrypt(CryptoKey, data, KeyWrapAlgorithm.Rsa) - assertEquals(CryptoCapability.decrypt(cipher), data) - } - }.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/JsItEnv.scala b/test/js/integration/JsItEnv.scala index a03440f..770e9bb 100644 --- a/test/js/integration/JsItEnv.scala +++ b/test/js/integration/JsItEnv.scala @@ -36,7 +36,7 @@ object JsItEnv: val AppPort: Int = 8391 val ServerAppId: AppId = AppId("js-it-server") - // Component names match the shared canonical set scripts/it/components/*.yaml. + // 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") diff --git a/test/js/integration/LockJsIntegrationTest.scala b/test/js/integration/LockJsIntegrationTest.scala index 361e950..33847e2 100644 --- a/test/js/integration/LockJsIntegrationTest.scala +++ b/test/js/integration/LockJsIntegrationTest.scala @@ -4,48 +4,23 @@ 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.* -/** [[LockCapability]] against a real `lock.redis` component — the Scala.js twin of [[LockCapabilityServerTest]]. Unique - * resource IDs per test keep the shared sidecar contention-free across runs. +/** 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: +class LockJsIntegrationTest extends FunSuite, LockScenarios: override def munitTimeout: Duration = 120.seconds - private def uniqueResource() = LockResourceId(s"js-it-res-${uniqueId()}") - private def uniqueOwner() = LockOwner(s"js-it-owner-${uniqueId()}") + private def run(body: DaprCapability ?=> Unit): Future[Unit] = + js.async(Dapr(clientConfig).run(body)).toFuture - test("lock: tryLock on a free resource returns true"): - js.async { - Dapr(clientConfig).run: - DaprCapability.lock(LockStore) { - assert(LockCapability.tryLock(uniqueResource(), uniqueOwner(), 30.seconds)) - } - }.toFuture - - test("lock: tryLock on a held resource returns false"): - js.async { - Dapr(clientConfig).run: - DaprCapability.lock(LockStore) { - val res = uniqueResource() - assert(LockCapability.tryLock(res, uniqueOwner(), 30.seconds), "first tryLock should succeed") - assert(!LockCapability.tryLock(res, uniqueOwner(), 30.seconds), "second tryLock should be contended") - } - }.toFuture - - test("lock: unlock by the owner returns Success, re-unlock returns LockNotFound"): - js.async { - Dapr(clientConfig).run: - DaprCapability.lock(LockStore) { - val res = uniqueResource() - val owner = uniqueOwner() - assert(LockCapability.tryLock(res, owner, 30.seconds)) - assertEquals(LockCapability.unlock(res, owner), UnlockStatus.Success) - assertEquals(LockCapability.unlock(res, owner), UnlockStatus.LockNotFound) - } - }.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/SecretsJsIntegrationTest.scala b/test/js/integration/SecretsJsIntegrationTest.scala index c690f77..4056d6d 100644 --- a/test/js/integration/SecretsJsIntegrationTest.scala +++ b/test/js/integration/SecretsJsIntegrationTest.scala @@ -4,54 +4,25 @@ 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.* -/** [[SecretsCapability]] against a real `secretstores.local.file` component (seeded from the shared - * `scripts/it/secrets.json`) — the Scala.js twin of [[SecretsCapabilityServerTest]]. - * - * A missing key THROWS rather than returning `None`: the local-file store answers 500, which rejects the SDK promise — - * the documented behaviour on both platforms (`SecretsCapabilityImpl`: "a sidecar error REJECTS the promise and - * propagates; None is returned only when the call succeeds but the response lacks the key"). +/** 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: +class SecretsJsIntegrationTest extends FunSuite, SecretsScenarios: override def munitTimeout: Duration = 120.seconds - test("secrets: get for a seeded key returns Some"): - js.async { - Dapr(clientConfig).run: - DaprCapability.secrets(SecretStore) { - assertEquals(SecretsCapability.get(SecretKey("it-secret-a")), Some(SecretValue("secret-value-alpha"))) - assertEquals(SecretsCapability.get(SecretKey("it-secret-b")), Some(SecretValue("secret-value-beta"))) - } - }.toFuture + private def run(body: DaprCapability ?=> Unit): Future[Unit] = + js.async(Dapr(clientConfig).run(body)).toFuture - test("secrets: getBulk contains the seeded keys"): - js.async { - Dapr(clientConfig).run: - DaprCapability.secrets(SecretStore) { - val bulk = SecretsCapability.getBulk() - // The bulk response nests {secretName: {key: value}}; dapr4s flattens to "name/key" compound keys. - assert( - bulk.exists { case (k, v) => k.value.contains("it-secret-a") && v.value == "secret-value-alpha" }, - s"expected it-secret-a in bulk result; got keys: ${bulk.keys.map(_.value).toList.sorted}", - ) - assert( - bulk.exists { case (k, v) => k.value.contains("it-secret-b") && v.value == "secret-value-beta" }, - "expected it-secret-b in bulk result", - ) - } - }.toFuture - - test("secrets: get for a missing key throws (local-file store answers 500)"): - js.async { - Dapr(clientConfig).run: - DaprCapability.secrets(SecretStore) { - val attempt = scala.util.Try(SecretsCapability.get(SecretKey(s"absent-${uniqueId()}"))) - assert(attempt.isFailure, s"expected a missing secret to throw, got: $attempt") - } - }.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 index 3deceb5..ad32bcf 100644 --- a/test/js/integration/StateJsIntegrationTest.scala +++ b/test/js/integration/StateJsIntegrationTest.scala @@ -4,115 +4,38 @@ 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.* -/** [[StateCapability]] against a real `state.redis` component, on the Wasm+JSPI backend — the Scala.js twin of - * [[StateCapabilityServerTest]]'s coverage (minus the HTTP-dispatch wrapping: here the capability itself IS the thing - * under test, called directly inside `Dapr.run`). +/** 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 (verified - * footgun: a vacuous pass). Requires the environment from `scripts/js-integration-env.sh up`. + * 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: +class StateJsIntegrationTest extends FunSuite, StateScenarios: override def munitTimeout: Duration = 120.seconds - private def uniqueKey() = StateStoreKey(s"js-it-k-${uniqueId()}") - - test("state: save then get returns the saved value"): - js.async { - Dapr(clientConfig).run: - DaprCapability.state(StateStore) { - val k = uniqueKey() - StateCapability.save(k, "hello-js") - assertEquals(StateCapability.get[String](k), Some("hello-js")) - } - }.toFuture - - test("state: get for a missing key returns None"): - js.async { - Dapr(clientConfig).run: - DaprCapability.state(StateStore) { - assertEquals(StateCapability.get[String](uniqueKey()), None) - } - }.toFuture - - test("state: getWithETag returns value and etag after save"): - js.async { - Dapr(clientConfig).run: - DaprCapability.state(StateStore) { - val k = uniqueKey() - StateCapability.save(k, "etagged") - val entry = StateCapability.getWithETag[String](k) - assertEquals(entry.value, Some("etagged")) - assert(entry.etag.isDefined, "ETag should be present after save") - } - }.toFuture - - test("state: saveWithETag succeeds with the current etag and conflicts with a wrong one"): - js.async { - Dapr(clientConfig).run: - DaprCapability.state(StateStore) { - val k = uniqueKey() - StateCapability.save(k, "v1") - val etag = StateCapability.getWithETag[String](k).etag.getOrElse(fail("expected an etag")) - assertEquals(StateCapability.saveWithETag(k, "v2", etag), None) - // The successful save bumped the server-side etag, so the one captured above is now - // STALE — a genuine optimistic-concurrency conflict. A fabricated string would not do - // here: Redis etags are integers, and daprd rejects a non-numeric etag with - // 400 ERR_STATE_SAVE (invalid etag value) instead of reporting a conflict. - assert( - StateCapability.saveWithETag(k, "v3", etag).isDefined, - "stale etag should yield a conflict", - ) - assertEquals(StateCapability.get[String](k), Some("v2")) - } - }.toFuture - - test("state: delete removes a key"): - js.async { - Dapr(clientConfig).run: - DaprCapability.state(StateStore) { - val k = uniqueKey() - StateCapability.save(k, "bye") - StateCapability.delete(k) - assertEquals(StateCapability.get[String](k), None) - } - }.toFuture - - test("state: saveBulk persists all entries and getBulk reads them (None for absent)"): - js.async { - Dapr(clientConfig).run: - DaprCapability.state(StateStore) { - val k1 = uniqueKey() - val k2 = uniqueKey() - val absent = uniqueKey() - StateCapability.saveBulk[String](List(k1 -> "alpha", k2 -> "beta")) - val results = StateCapability.getBulk[String](List(k1, k2, absent)) - assertEquals(results.get(k1).flatMap(_.value), Some("alpha")) - assertEquals(results.get(k2).flatMap(_.value), Some("beta")) - assertEquals(results.get(absent).flatMap(_.value), None) - } - }.toFuture - - test("state: transaction upserts and deletes atomically"): - js.async { - Dapr(clientConfig).run: - DaprCapability.state(StateStore) { - val kAdd = uniqueKey() - val kDel = uniqueKey() - 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) - } - }.toFuture + 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/jvm/integration/ConfigurationCapabilityServerTest.scala b/test/jvm/integration/ConfigurationCapabilityServerTest.scala deleted file mode 100644 index 26a2836..0000000 --- a/test/jvm/integration/ConfigurationCapabilityServerTest.scala +++ /dev/null @@ -1,91 +0,0 @@ -//> using target.platform "jvm" -package dapr4s.test.integration - -import dapr4s.* -import dapr4s.given -import io.dapr.testcontainers.{Component, DaprContainer} -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 - -/** Tests for [[ConfigurationCapability]] against a real `configuration.redis` component — the JVM twin of - * [[ConfigurationJsIntegrationTest]] (same keys, same `value||version` seeding, same assertions). - * - * Dapr has no in-memory configuration store, so this uses Redis on a shared Docker network (like - * [[InventoryServiceIntegrationTest]]'s `lock.redis`). Keys are seeded by `redis-cli MSET` inside the Redis container - * — the in-container equivalent of the JS harness's `docker exec ... redis-cli MSET`; Dapr's redis configuration store - * splits `value||version` into value + version. - */ -@scala.caps.assumeSafe -class ConfigurationCapabilityServerTest extends FunSuite with TestContainersForAll: - - type Containers = GenericContainer and DaprTestContainer - - private val Store = ConfigurationStoreName("configstore") - - 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() - - // Seed before daprd reads: Dapr's redis configuration store stores "value||version". - val seed = redis.container.execInContainer( - "redis-cli", - "MSET", - "dapr4s-cfg-a", - "alpha||v1", - "dapr4s-cfg-b", - "beta||v2", - ) - assertEquals(seed.getExitCode, 0, s"redis MSET failed: ${seed.getStderr}") - - val c = DaprTestContainer( - DaprContainer(DaprTestContainer.DefaultImage) - .withNetwork(network) - .withAppName("configuration-server-test") - .withAppPort(0) - .withComponent( - Component("configstore", "configuration.redis", "v1", java.util.Map.of("redisHost", "redis:6379")), - ) - .dependsOn(redis.container), - ) - c.start() - redis and c - - test("configuration: get returns the seeded items with values and versions"): - withContainers { case _ and c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - DaprCapability.configuration(Store) { - val keyA = ConfigurationKey("dapr4s-cfg-a") - val keyB = ConfigurationKey("dapr4s-cfg-b") - val items = ConfigurationCapability.get(Seq(keyA, keyB)) - val a = items.getOrElse(keyA, fail(s"missing $keyA in $items")) - val b = items.getOrElse(keyB, fail(s"missing $keyB in $items")) - assertEquals(a.value, ConfigurationValue("alpha")) - assertEquals(a.version, ConfigurationVersion("v1")) - assertEquals(b.value, ConfigurationValue("beta")) - assertEquals(b.version, ConfigurationVersion("v2")) - } - } - - test("configuration: get for an unknown key returns no item for it"): - withContainers { case _ and c => - Dapr.runWithEndpoints(c.httpEndpoint, c.grpcEndpoint): - DaprCapability.configuration(Store) { - val absent = ConfigurationKey("dapr4s-cfg-absent") - val items = ConfigurationCapability.get(Seq(absent)) - assertEquals(items.get(absent), None) - } - } 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/jvm/integration/CryptoCapabilityServerTest.scala b/test/jvm/integration/CryptoCapabilityServerTest.scala deleted file mode 100644 index f152f66..0000000 --- a/test/jvm/integration/CryptoCapabilityServerTest.scala +++ /dev/null @@ -1,76 +0,0 @@ -//> using target.platform "jvm" -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/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/jvm/integration/JvmItComponents.scala b/test/jvm/integration/JvmItComponents.scala new file mode 100644 index 0000000..965282e --- /dev/null +++ b/test/jvm/integration/JvmItComponents.scala @@ -0,0 +1,80 @@ +//> 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, its rendered component file Paths, the keys dir and the secrets.json file + * (ready to mount into daprd). + */ + final case class Rendered(root: Path, components: List[Path], keysDir: Path, secretsFile: Path) + + /** 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) + out + } + // 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/LockCapabilityServerTest.scala b/test/jvm/integration/LockCapabilityServerTest.scala deleted file mode 100644 index db5f50e..0000000 --- a/test/jvm/integration/LockCapabilityServerTest.scala +++ /dev/null @@ -1,208 +0,0 @@ -//> 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.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/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/jvm/integration/SecretsCapabilityServerTest.scala b/test/jvm/integration/SecretsCapabilityServerTest.scala deleted file mode 100644 index c4e4fde..0000000 --- a/test/jvm/integration/SecretsCapabilityServerTest.scala +++ /dev/null @@ -1,120 +0,0 @@ -//> 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 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/jvm/integration/SecretsIntegrationTest.scala b/test/jvm/integration/SecretsIntegrationTest.scala deleted file mode 100644 index c54c60b..0000000 --- a/test/jvm/integration/SecretsIntegrationTest.scala +++ /dev/null @@ -1,44 +0,0 @@ -//> using target.platform "jvm" -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/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..ab013b8 --- /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 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/StateCapabilityServerTest.scala b/test/jvm/integration/StateCapabilityServerTest.scala deleted file mode 100644 index 6d262cb..0000000 --- a/test/jvm/integration/StateCapabilityServerTest.scala +++ /dev/null @@ -1,411 +0,0 @@ -//> 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 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/jvm/integration/StateIntegrationTest.scala b/test/jvm/integration/StateIntegrationTest.scala deleted file mode 100644 index e07bf88..0000000 --- a/test/jvm/integration/StateIntegrationTest.scala +++ /dev/null @@ -1,116 +0,0 @@ -//> 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 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/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/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/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) From 0875bcd02921237cede37447a7427644406315ec Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Sat, 13 Jun 2026 01:39:21 +0200 Subject: [PATCH 16/17] test(it): unify invoke into shared scenarios + close the JS error-path gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InvokeScenarios (test/shared) holds the caller side — echo, falsy-0, the derived EchoService facade, and the non-existent-app error path — with two per-platform hooks: serverAppId and a retrying strategy (identity on JVM where sidecar health is polled up front; retryUntilSuccess on JS for app-channel warmup). The server bring-up stays platform-specific (a host DaprAppServer on JVM, JsItServerApp on JS) since the server runtimes differ. Both platforms now run the SAME four invoke scenarios — including the non-existent-app error path, which previously had no JS twin (it was the JVM-only InvokeIntegrationTest). Replaces InvokeCapabilityServerTest + InvokeIntegrationTest with InvokeItTest; JS InvokeJsIntegrationTest gains the error-path test. Co-Authored-By: Claude Opus 4.8 --- .../integration/InvokeJsIntegrationTest.scala | 55 ++----- .../InvokeCapabilityServerTest.scala | 139 ------------------ .../integration/InvokeIntegrationTest.scala | 45 ------ test/jvm/integration/InvokeItTest.scala | 95 ++++++++++++ test/shared/scenarios/InvokeScenarios.scala | 45 ++++++ 5 files changed, 154 insertions(+), 225 deletions(-) delete mode 100644 test/jvm/integration/InvokeCapabilityServerTest.scala delete mode 100644 test/jvm/integration/InvokeIntegrationTest.scala create mode 100644 test/jvm/integration/InvokeItTest.scala create mode 100644 test/shared/scenarios/InvokeScenarios.scala diff --git a/test/js/integration/InvokeJsIntegrationTest.scala b/test/js/integration/InvokeJsIntegrationTest.scala index 7993fb6..19a6e26 100644 --- a/test/js/integration/InvokeJsIntegrationTest.scala +++ b/test/js/integration/InvokeJsIntegrationTest.scala @@ -3,58 +3,31 @@ package dapr4s.test.integration import dapr4s.* import dapr4s.given -import dapr4s.test.integration.apps.{CounterState, EchoService, IncrRequest} import munit.FunSuite +import scala.concurrent.Future import scala.concurrent.duration.{Duration, DurationInt} import scala.scalajs.js import unsafeExceptions.canThrowAny import JsItEnv.* -/** [[InvokeCapability]] end to end through the sidecar to the JS test server's invoke routes ([[JsItServerApp]]) — the - * Scala.js twin of [[InvokeCapabilityServerTest]], including the derived [[EchoService]] caller facade. +/** 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 falsy-`0` test exercises the raw-fetch fallback in `InvokeCapabilityImpl` (the JS SDK silently drops JS-falsy - * request bodies — `if (params?.body)` in HTTPClient.js). - * - * The first call retries: daprd reports healthy slightly before the app channel finishes warming up, mirroring the - * startup polling the JVM twins do. + * The first call retries: daprd reports healthy slightly before the app channel finishes warming up. */ @scala.caps.assumeSafe -class InvokeJsIntegrationTest extends FunSuite: +class InvokeJsIntegrationTest extends FunSuite, InvokeScenarios: override def munitTimeout: Duration = 120.seconds - test("invoke: echo roundtrip via the test server"): - js.async { - Dapr(clientConfig).run: - DaprCapability.invoke { - val resp = retryUntilSuccess("echo through app channel") { - InvokeCapability.invoke(ServerAppId, InvokeMethodName("echo"), "hello-js")[String] - } - assertEquals(resp, "hello-js") - } - }.toFuture + protected def serverAppId: AppId = ServerAppId + protected def retrying[T](label: String)(body: => T): T = retryUntilSuccess(label)(body) - test("invoke: falsy body 0 reaches the handler via the raw-fetch fallback"): - js.async { - Dapr(clientConfig).run: - DaprCapability.invoke { - val resp = retryUntilSuccess("echo-int through app channel") { - InvokeCapability.invoke(ServerAppId, InvokeMethodName("echo-int"), 0)[Int] - } - assertEquals(resp, 0) - } - }.toFuture + private def run(body: DaprCapability ?=> Unit): Future[Unit] = + js.async(Dapr(clientConfig).run(body)).toFuture - test("invoke: derived EchoService facade calls the matching server routes"): - js.async { - Dapr(clientConfig).run: - DaprCapability.invoke { - val service = EchoService(ServerAppId) - val echoed = retryUntilSuccess("derived echo through app channel") { - service.echo("derived-js") - } - assertEquals(echoed, "derived-js") - assertEquals(service.double(IncrRequest(21)), CounterState(42)) - } - }.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/jvm/integration/InvokeCapabilityServerTest.scala b/test/jvm/integration/InvokeCapabilityServerTest.scala deleted file mode 100644 index 76dd65c..0000000 --- a/test/jvm/integration/InvokeCapabilityServerTest.scala +++ /dev/null @@ -1,139 +0,0 @@ -//> 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, 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/jvm/integration/InvokeIntegrationTest.scala b/test/jvm/integration/InvokeIntegrationTest.scala deleted file mode 100644 index df3bfe7..0000000 --- a/test/jvm/integration/InvokeIntegrationTest.scala +++ /dev/null @@ -1,45 +0,0 @@ -//> using target.platform "jvm" -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/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/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") From 3c23c867195fa5c76619f72b94b2f03ed3044ec9 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Sat, 13 Jun 2026 11:09:12 +0200 Subject: [PATCH 17/17] =?UTF-8?q?test(it):=20redis=20everywhere=20?= =?UTF-8?q?=E2=80=94=20convert=20remaining=20server-delivery=20suites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finish the 'redis everywhere' conversion: every JVM integration suite now runs on the shared scripts/it/components set instead of state.in-memory/pubsub.in-memory, matching the JS harness. - New RedisFixture: stands up a Redis container (managed outside testcontainers-scala's Containers, so suites keep their Containers type and unchanged test bodies) and renders the shared components, for the bespoke server-delivery suites that cannot use SharedDaprItSuite (host DaprAppServer the sidecar calls back into; two-phase actor/ workflow startup). - Converted PubSub/Publish/Actor/Workflow + the app-level Order/Inventory/EndToEnd suites to feed daprd the shared rendered manifests via DaprContainer.withComponent(Path); actor/workflow state.redis (actorStateStore) on the SHARED network. - JvmItComponents.render now keys components by name (rendered.component("statestore")). Verified: all 15 JVM integration suites pass on redis (Actor 14, Workflow 5, Publish 6, PubSub 3, Order/Inventory/EndToEnd 7 each, + the direct-call ItTests). JS unchanged (already on redis). Docs: DESIGN.md parity section + docs/JVM-JS-PARITY.md (done) + wiki. Co-Authored-By: Claude Opus 4.8 --- docs/DESIGN.md | 42 ++++++++++++------- docs/JVM-JS-PARITY.md | 22 ++++++---- .../ActorCapabilityServerTest.scala | 12 +++--- .../integration/EndToEndIntegrationTest.scala | 13 +++--- .../InventoryServiceIntegrationTest.scala | 13 +++--- test/jvm/integration/JvmItComponents.scala | 9 ++-- .../OrderServiceIntegrationTest.scala | 20 +++++---- .../integration/PubSubIntegrationTest.scala | 25 +++++------ .../PublishCapabilityServerTest.scala | 20 +++++---- test/jvm/integration/RedisFixture.scala | 42 ++++++++----------- .../WorkflowCapabilityServerTest.scala | 10 ++--- wiki/log.md | 5 +++ 12 files changed, 125 insertions(+), 108 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d23c284..2e7cfea 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -674,29 +674,41 @@ dapr4s/ │ ├── unit/ # JVM-server tests: SubscriberTest, BindingDispatchTest, JobDispatchTest, │ │ # DaprServerTestBase, JvmCapabilityDerivationTest (+ fixtures), │ │ # JvmModelsTest, JvmServerRouteDerivationTest - │ ├── integration/ # Docker/testcontainers suites: TestDaprApp + DaprTestContainer harnesses, - │ │ # per-capability *CapabilityServerTest (State/Publish/Secrets/Lock/Actor/ - │ │ # Invoke/Configuration/Workflow/Jobs/Crypto/Conversation), State/PubSub/Invoke/ - │ │ # Secrets/OrderService/InventoryService/EndToEnd IntegrationTests + │ ├── 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 suites against a live sidecar (see Scala.js platform section): - # State/PubSub/Invoke/Secrets/Configuration/Lock/Actor/Workflow/Crypto + └── 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`. The two -platforms drive the sidecar differently — the JVM via [testcontainers-dapr](https://github.com/diagridio/testcontainers-dapr), -whose idiomatic API declares components programmatically (`DaprContainer.withComponent(Component(name, type, version, -metadata))` inside each suite's `startContainers()`); the JS layer has no such library, so `scripts/js-integration-env.sh` -drives raw `daprd` under Docker with on-disk component YAMLs under `scripts/js-it/components/` (which is exactly what -testcontainers-dapr writes for you under the hood on the JVM). That is why there is no `scripts/jvm-it/components/` -directory: the JVM never needs component files on disk. The two stay equivalent in *content* — same component types, same -Redis `value||version` seeding for configuration, same crypto key material — rather than sharing one source, which would -mean abandoning the JVM's programmatic API or hand-rolling a YAML loader. +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. diff --git a/docs/JVM-JS-PARITY.md b/docs/JVM-JS-PARITY.md index 5d3e0ee..858e6c3 100644 --- a/docs/JVM-JS-PARITY.md +++ b/docs/JVM-JS-PARITY.md @@ -1,9 +1,16 @@ # JVM ↔ Scala.js test & config parity -Status: in progress (PR #38). Goal: the JVM and Scala.js variants should share as much +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 @@ -80,10 +87,11 @@ The shared scenario backbone targets the **direct-call** form (what the JVM `*In 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) +## Execution order (incremental, format → compile → test after each) — all done -1. Canonical `scripts/it/components/*.yaml` + render step. (#10) -2. Point the JS harness at it. (#11) -3. Point the JVM suites at it; in-memory→redis, env→file. (#12) -4. Shared scenario traits + thin shells; close coverage/naming gaps. (#13) -5. Verify both platforms locally, update docs/wiki, push, keep PR #38 green. (#14) +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/test/jvm/integration/ActorCapabilityServerTest.scala b/test/jvm/integration/ActorCapabilityServerTest.scala index 62d4509..428afc7 100644 --- a/test/jvm/integration/ActorCapabilityServerTest.scala +++ b/test/jvm/integration/ActorCapabilityServerTest.scala @@ -7,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. @@ -39,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 @@ -85,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/EndToEndIntegrationTest.scala b/test/jvm/integration/EndToEndIntegrationTest.scala index f851bc9..2cfea56 100644 --- a/test/jvm/integration/EndToEndIntegrationTest.scala +++ b/test/jvm/integration/EndToEndIntegrationTest.scala @@ -5,7 +5,7 @@ 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 @@ -14,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. * @@ -43,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/jvm/integration/InventoryServiceIntegrationTest.scala b/test/jvm/integration/InventoryServiceIntegrationTest.scala index 6e4750c..9e585e1 100644 --- a/test/jvm/integration/InventoryServiceIntegrationTest.scala +++ b/test/jvm/integration/InventoryServiceIntegrationTest.scala @@ -5,7 +5,7 @@ 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 @@ -14,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. * @@ -40,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/JvmItComponents.scala b/test/jvm/integration/JvmItComponents.scala index e11191c..5d017ca 100644 --- a/test/jvm/integration/JvmItComponents.scala +++ b/test/jvm/integration/JvmItComponents.scala @@ -34,13 +34,16 @@ object JvmItComponents: 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). + /** 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}")) + 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 = diff --git a/test/jvm/integration/OrderServiceIntegrationTest.scala b/test/jvm/integration/OrderServiceIntegrationTest.scala index b34fb40..ba804fe 100644 --- a/test/jvm/integration/OrderServiceIntegrationTest.scala +++ b/test/jvm/integration/OrderServiceIntegrationTest.scala @@ -5,30 +5,32 @@ 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/jvm/integration/PubSubIntegrationTest.scala b/test/jvm/integration/PubSubIntegrationTest.scala index 18f76e5..07efd3b 100644 --- a/test/jvm/integration/PubSubIntegrationTest.scala +++ b/test/jvm/integration/PubSubIntegrationTest.scala @@ -3,31 +3,28 @@ 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/jvm/integration/PublishCapabilityServerTest.scala b/test/jvm/integration/PublishCapabilityServerTest.scala index 3fffae4..c0718b5 100644 --- a/test/jvm/integration/PublishCapabilityServerTest.scala +++ b/test/jvm/integration/PublishCapabilityServerTest.scala @@ -4,32 +4,34 @@ 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 index c4b7bd1..6d5ad25 100644 --- a/test/jvm/integration/RedisFixture.scala +++ b/test/jvm/integration/RedisFixture.scala @@ -1,51 +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.{GenericContainer, Network} +import org.testcontainers.containers.Network import org.testcontainers.containers.wait.strategy.Wait -import org.testcontainers.utility.DockerImageName /** 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"). + * 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 raw 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 + * 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 + 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(DockerImageName.parse("redis:7-alpine")) - .nn - .withNetwork(network) - .nn - .withNetworkAliases(JvmItComponents.RedisAlias) - .nn - .waitingFor(Wait.forLogMessage(".*Ready to accept connections.*", 1)) - .nn + 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() - /** Seed the configuration items both harnesses use (only the configuration suite needs this). */ - protected def seedConfig(): Unit = - val r = redis - require(r != null, "startRedis must be called before seedConfig") - val args = Array("redis-cli", "MSET") ++ JvmItComponents.SeededConfig.flatMap((k, v) => List(k, v)) - val res = r.nn.execInContainer(args*) - assertEquals(res.getExitCode, 0, s"redis MSET failed: ${res.getStderr}") - override def afterAll(): Unit = super.afterAll() val r = redis - if r != null then r.nn.stop() + if r != null then r.stop() diff --git a/test/jvm/integration/WorkflowCapabilityServerTest.scala b/test/jvm/integration/WorkflowCapabilityServerTest.scala index 5ea78e2..1088bcb 100644 --- a/test/jvm/integration/WorkflowCapabilityServerTest.scala +++ b/test/jvm/integration/WorkflowCapabilityServerTest.scala @@ -6,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. @@ -25,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 @@ -48,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/wiki/log.md b/wiki/log.md index d838509..d589e2c 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1,5 +1,10 @@ # 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".