feat: cross-compile dapr4s to Scala.js — ScalablyTyped facades over the Dapr JS SDK, Wasm+JSPI direct style#38
Merged
Conversation
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
…files
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ecar 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…age` 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…h gap 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 <noreply@anthropic.com>
…-build # Conflicts: # wiki/index.md
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 <noreply@anthropic.com>
sideeffffect
added a commit
that referenced
this pull request
Jun 13, 2026
The cross-build split (v0.20.0, #38) moved the JVM-only conversation models into src/jvm/ConversationModels.scala, which is deliberately NOT compiled in safe mode (its safe import tripped a 3.10.0-RC1 capture-checker bug on unrelated optypes enums). As a side effect the object ConversationMessage's explicitly-declared factory `def`s (system/user/assistant/tool/developer) became unreferable from downstream safe-mode code — breaking the dapr4s-examples conversation demo, which constructs messages from a safe PureModule. Annotate object ConversationMessage @scala.caps.assumeSafe (the same escape hatch ConversationCapability and the other src/jvm objects already use). The factories are pure case-class wrappers, so erasing their empty capture set is sound. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
dapr4s now cross-compiles to Scala.js, publishing
dapr4s_sjs1_3alongsidedapr4s_3. The public API is source-identical on both platforms — same capability traits, sameDaprApp, same derivation macros, same direct (synchronous-looking) style. On JS the internal layer is backed by the Dapr JavaScript SDK (@dapr/dapr3.18.0) through ScalablyTyped-generated facades.How direct style survives on JavaScript
JS cannot block, and the project's design forbids an async/
FutureAPI fork (it would also fork the whole derivation layer). The one mechanism that preservesdef get(key): Option[T]is the experimental WebAssembly backend + JSPI: every capability call suspends on the SDK promise via an orphanjs.await(single site:src/js/internal/JsAwait.scala) — the exact analogue of a virtual thread parking inCompletableFuture.get(). Inbound requests each re-enterjs.async(one "virtual thread" per request). JS consumers link withjsEmitWasm+ ES modules on Node 25+; on the plain JS backend the pure parts (models, codecs, derivation) still link, and capability code fails at link time.Layout
Platform-specific dependencies are
target.platform-scoped files —jvm-deps.scala(Dapr Java SDK),jvm-test-deps.test.scala(testcontainers),js-deps.scala(ScalablyTyped facades). Ausing depin a platform-tagged file applies only to that platform's build and POM, so there are no--excludeflags for dependency scoping andscala-cli compile --js .just works. (There is no--includein scala-cli — positional re-include of excluded files is silently ignored; thetarget.platformscoping turned out strictly better than both.)ScalablyTyped facades (generated in the build, never committed)
scripts/generate-st-facades.shruns the ScalablyTyped converter (cli_3:1.0.0-beta45,--scala 3.3.6 --scalajs 1.21.0 -s es2022) over the npm packages pinned inpackage.json/package-lock.jsonand publishes typed facades to~/.ivy2/local;js-deps.scalapins the resulting coordinates (digests are deterministic from lockfile + converter version + flags). CI generates them with a cache; one hand-written shim survives (ExpressModule.scala— ST'sexpress()is not callable under Node ESM).Platform-diverging surfaces: compile-time absence, not runtime exceptions
DaprCapability(shared) extendsDaprCapabilityPlatform(per-platform); its companion extendsDaprCapabilityCompanionPlatform. On the JVM the platform trait contributesjobs/conversation; on JS those members don't exist (the JS SDK has no jobs/conversation API), so misuse is a compile error instead of anUnsupportedOperationException.JobsCapability/ConversationCapability, their models,Jobs.derive, and the job forwarders live undersrc/jvm. The same technique (Forwarders extends ForwardersPlatform) covers the derivation layer.What's implemented on JS
DaprClient(HTTP)DaprClientDaprWorkflowClient(gRPC)fetch) — the SDK doesn't export its low-level actor clientserve(): subscriptions, invoke routes, bindings, job routes, actor hostingDaprAppServertwin (the JVM also hand-rolls the app channel)serve(): workflow + activity hostingWorkflowRuntime+ a hand-written AsyncGenerator coroutine bridge (Task.await()yields the SDK task, JSPI suspends the fiber); deterministicnewUuidmirrors the Java SDK's v5/SHA-1Testing (all in CI via a platform matrix)
ci.ymlhas onetestmatrix job (jvm,js— Scala Native someday is one more include entry). Each leg runs compile + unit + integration:scripts/test-js-integration.sh): state incl. genuine stale-etag conflicts, pubsub round-trip through a served subscription, invoke echo, secrets, lock, configuration (gRPC), actor client→hosted actor→actor state, workflows (activity output +raiseEventthrough a gated workflow). Verified stable across repeated local runs.Harness facts worth knowing (all documented in AGENTS.md/wiki): the plain-JS linker wedges on orphan-await test sources (hence
--exclude test/js/integrationon the unit leg — the one remaining exclude, a linker workaround); scala-cli 1.14 exits 1 after green Wasm test runs (cleanup bug, tolerated precisely byscripts/wasm-test.sh); scala-cli runs the linked ES module from/tmp, where bare npm specifiers don't resolve — fixed with aNODE_OPTIONS --importresolution hook;--test-onlyis ineffective on the JS runner;UUID.randomUUID()doesn't link on Scala.js.Verification beyond CI
if (params?.body)silently drops falsy JSON payloads0/false/""/null; dapr4s routes those through raw sidecar HTTP, delivery re-verified via redis stream contents).publish local:dapr4s_sjs1_3POM contains zeroio.dapr/testcontainers coordinates; jar contents correctly split.Known limitations / follow-ups
@dapr/daprtask-hub-grpc-workerisFirstAttemptbug (daprd restart permanently kills the workflow worker).The repo wiki gained a
scala-jstopic (cross-building, async/JSPI/Wasm, capture-checking-on-JS, ScalablyTyped-with-scala-cli) and adapr/dapr-js-sdk.mdAPI map with field notes from this port.🤖 Generated with Claude Code