Skip to content

feat: @rivetkit/effect SDK#4703

Draft
IGassmann wants to merge 117 commits intorivet-dev:mainfrom
IGassmann:feat/effect-sdk-design
Draft

feat: @rivetkit/effect SDK#4703
IGassmann wants to merge 117 commits intorivet-dev:mainfrom
IGassmann:feat/effect-sdk-design

Conversation

@IGassmann
Copy link
Copy Markdown

@IGassmann IGassmann commented Apr 23, 2026

Summary

Introduces @rivetkit/effect, an Effect-based SDK for Rivet Actors.

What's here

  • examples/effect/ — proposed API surface
  • rivetkit-typescript/packages/effect/@rivetkit/effect package

Looking for feedback on the API's design.

Benefits

  • Typed errors. The current SDK throws untyped exceptions (throw new UserError("...", { code: "..." })). Callers catch unknown. The Effect SDK puts errors in the E channel: Effect<number, CounterOverflowError>. The client sees the exact union of possible errors at compile time.
  • Runtime validation at the boundary. The current SDK's action args ((c, amount: number)) are TypeScript-only. Types are erased at runtime. A malicious or drifted-version client can send anything. The Effect SDK validates wire data with Schema.decodeUnknown before it reaches handler code.
  • Runtime dependency tracking in the type system. Counter.client depends on Client in the R channel. If you forget Layer.provide, you get a compile error naming the missing runtime dependency. The current SDK's typeof registry gives type-safe method calls, but doesn't track the Effect runtime dependency.
  • Testability via Layer swapping. Any service (KV, DB, state, events) can be replaced by providing a different Layer. The current SDK has setupTest but swapping individual services requires the test framework's internals.
  • Resource safety. Effect's Scope and addFinalizer guarantee cleanup runs even on errors or interruption. The current SDK has lifecycle hooks (onSleep, onDestroy) but they're not compositional. You can't nest scopes or attach finalizers from within action handlers.
  • Wire encoding control. Effect Schema distinguishes encoded (wire) and decoded (runtime) types. Schema.Date decodes an ISO string into a Date, Schema.BigInt decodes a string into a BigInt. The current SDK has no equivalent — you get whatever JSON gives you.
  • Effect Queue message processing. The current SDK processes queue messages through an async iterator (c.queue.iter()). The Effect SDK exposes durable messages as an Effect Queue.Dequeue, giving you batching, concurrent consumers via forked fibers, and composability with Stream, Schedule, and other Effect primitives out of the box.
  • Distributed tracing across actor calls. Effect spans propagate from caller to handler across the wire. Span shape follows OTel RPC conventions (${actor}/${action} name, kind: "client" | "server", rpc.system.name / rpc.method attributes), and user-defined sub-spans inside handlers nest correctly under the SDK's server span.

Tasks

  • 🔜 Run Schema.encode / decode at the state persistence boundary
  • 🔜 Implement the "fires the wake-scope finalizer on sleep" test case
  • Refactor current implementation
  • Bridge Rivetkit aborts and Effect interruption
  • Improve mapping of plain SDK errors to Effect SDK errors/defects
  • Counter.Events API
  • Messages/queues API
  • Actor-to-actor communication
  • Connections API
  • Actor.Db API
  • Schedules API
  • Get actor's ID from client
  • Authorization API
  • Runner.serve(), ...
  • Allow actor state to be swappable during testing
  • Proper TSDoc
  • README for @rivetkit/effect
  • Plain RivetKit ↔ Effect actor interop
  • Find solution for "Rivetkit's auto-spawned engine persists state across runs, so each pnpm test inherits prior runs' dead-envoy actor bindings and stalls on the engine's lost-envoy threshold"

Open Questions

State initialization

  • How to define default initial values (current SDK: state: { count: 0 })
  • How to support dynamic state from creation input (current SDK: createState(c, input))
  • Client-side getOrCreate(key, { createWithInput: { start: 10 } }) equivalent

State persistence semantics

  • When/how are SubscriptionRef changes persisted? On every Ref.update, or batched?
  • What atomicity guarantees exist within an action? If an action updates state twice, is each update persisted or only the final value?
  • What is the equivalent of stateSaveInterval (current SDK batches writes at a configurable interval)?

Configuration and timeouts

  • How to configure per-actor timeouts (action timeout, sleep timeout, connection liveness)?
  • Where do options like noSleep, maxQueueSize, maxQueueMessageSize live?
  • Should these be in Actor.make options, toLayer options, or registry-level config?

Connections

  • Per-connection state from params (current SDK: createConnState(c, params))
  • Accessing connection state in actions (current SDK: c.conn.state)
  • Iterating all connections (current SDK: c.conns)
  • Connection lifecycle hooks (onBeforeConnect, onConnect, onDisconnect)

Scheduling

  • Delayed action invocation (current SDK: c.schedule.after(delayMs, "actionName", ...args))

Lifecycle hooks

  • onCreate, onDestroy
  • onBeforeConnect, onConnect, onDisconnect
  • onStateChange, onBeforeActionResponse

Actor self-awareness and control

  • Actor key access (current SDK: c.key)
  • Self-destruction (current SDK: c.destroy())

Low-level handlers

  • Raw HTTP handler (current SDK: onRequest)
  • Raw WebSocket handler (current SDK: onWebSocket)

Client ergonomics

  • Positional action args (current SDK: counter.increment(5)) vs single-object payload (counter.increment({ amount: 5 }))

Access control for events and queues

  • The current SDK has canSubscribe on events and canPublish on queues — guards that receive the connection context and return a boolean to allow/deny a specific client. How should these map to the Effect design?

Mixing Effect and plain RivetKit actors

  • Can plain RivetKit actors be registered alongside Effect actors in the same Registry.layer?
  • A utility like Registry.fromPlain({ chatRoom }) could wrap plain actor configs into a Layer for composition, without requiring schema conversion
  • On the client side, calling a plain actor from Effect code would need to be handled.

Future Improvements

  • Per-actor client services. Reconsider the client dependency model by replacing the central Counter.client projection with a per-actor service model (Counter.Client plus Counter.clientLayer). This would make application effects depend on the exact actor clients they use instead of the shared Client, improving dependency readability, enabling selective test fakes per actor, supporting per-actor endpoint/token/project overrides through layers, and allowing higher-level services to mock or swap one actor client without replacing the entire transport.
  • Unified tracing across the Effect SDK, rivetkit actor instance, and engine. Today, three disjoint span trees are produced per action call: the engine's HTTP request tree (rooted at http_request with parent: None), rivetkit's actor instance tree (rooted at actor.action.<name> via @rivetkit/traces, gated by RIVET_EXPERIMENTAL_OTEL), and the Effect SDK tree (the only one that crosses the wire today, via ActionMeta.trace + Tracer.externalSpan). Joining them would require: (1) the engine's api-builder/guard-core middleware to extract traceparent and replace parent: None, plus forwarding context across the engine→envoy hop; (2) rivetkit's executeAction to read the wire trace context (from ActionMeta or traceparent) and parent actor.action.<name> to it; (3) the Effect SDK to either reparent its server span under rivetkit's actionSpan via a context tag, or migrate to @effect/opentelemetry so both sides emit through one OTel pipeline with standard W3C propagation. End state: a single trace from caller through engine through actor host through handler sub-spans.

@IGassmann IGassmann marked this pull request as draft April 23, 2026 11:09
@IGassmann IGassmann force-pushed the feat/effect-sdk-design branch 6 times, most recently from 3f26ccf to 9c7bc2a Compare April 27, 2026 08:36
@IGassmann IGassmann changed the title feat: Effect SDK API design proposal feat: @rivetkit/effect SDK Apr 27, 2026
@IGassmann IGassmann changed the title feat: @rivetkit/effect SDK feat: @rivetkit/effect SDK Apr 27, 2026
@IGassmann IGassmann force-pushed the feat/effect-sdk-design branch 2 times, most recently from 08bda7b to 671b6de Compare April 29, 2026 13:50
Add examples/effect with the proposed API surface for @rivetkit/effect.
This is a design-only example for feedback, not a working implementation.
Move action schemas inline into Actor.make instead of separate
Action.make declarations. Reduces boilerplate and eliminates the
name-sync problem across three declaration sites.
Remove the Exit-matching create/destroy pattern from the wake scope
finalizer. Create and destroy are separate lifecycle events that
don't map to scope exit signals.
IGassmann added 30 commits May 3, 2026 11:58
Replace hand-rolled `Options`, `EngineOptions`, and `ClientOptions`
declarations with `Pick`s of the canonical rivetkit input types
(`GlobalActorOptionsInput`, `RegistryConfigInput`, `ClientConfigInput`)
to prevent drift, and use `WakeContextOf`/`SleepContextOf` for the
wake/sleep callback parameters instead of structural sub-shapes.
Derive `ActorAddress` from `Rivetkit.ActorContext` instead of
hand-rolling the field types, and reuse `ActorKeyParam` for
`ClientShape.callAction.params.key` in place of the duplicated
`string | ReadonlyArray<string>` shape.
`Runner.test` is the Effect-Cluster-`TestRunner.layer` analogue: one
`Layer.effectContext` that boots the rivetkit registry in test mode,
auto-spawns the engine when no endpoint is configured, and provides
`Runner | Client` so consumers wire the test runtime in a single
`Layer.provideMerge`.

Adds a `Runner.test.ts` exercising the wire path against a Counter
actor (success, in-wake state, typed-error round-trip), plus a
`globalSetup` that wipes orphaned engine state so repeated `pnpm test`
invocations are deterministic.
Override RIVET_LOG_LEVEL=ERROR in the package vitest config so the
spawned rivet-engine and runtime stop flooding the terminal with DEBUG
output during test runs.
…actor

Extends the e2e Counter fixture with EchoDate, Tags (custom CSV
transform), Greet, and WakeGreeting actions plus a Greeter user
service yielded both in the wake scope and inside an action handler.
Adds a minimal Pinger actor used solely for the multi-actor
registration test. Fills in the four TODO test cases and adds
`registers and serves multiple actors`.
Runner.test now reads the resolved endpoint from rivetkitRegistry's
parsed config and passes it to createClient when the user didn't
supply one. Suppresses the rivetkit "No endpoint provided" warning
that fired on every test run when the engine was auto-spawned.
Adds an end-to-end test asserting that an action's payload, success,
and error all resolve their schema decoding/encoding services on both
sides of the wire. Drives a `Multiplier`-dependent `ScaledNumber`
schema through a new `Scale` action on the existing `Counter` actor.
Adds a `FailingActor` whose `toLayer` build dies, registers it with
the test runner, and asserts that calling an action on it surfaces a
`RivetError` to the client.
Captures today's behavior when a client calls an actor whose `*Live`
layer was never provided: the engine logs the precise `not_registered`
reason but flattens it on the wire to `guard/service_unavailable`, the
same generic code a transient engine outage surfaces as. Locking the
mapping in a test forces a review if/when the engine starts emitting a
distinct code so the SDK can decode it into a typed `ActorNotFound`.
Action calls now ride a wire-side `ActionMeta` envelope (`args[1]`)
that carries the caller's span IDs. The client-side wrapper opens a
child span via `Effect.fn`, the server-side wrapper reattaches it as
`Tracer.externalSpan` parent so handlers and any nested user spans
join the same trace. Span names follow OTel RPC conventions
(`${actor}/${action}` + `kind` + `rpc.system.name` / `rpc.method`).
Refactor sleep teardown to use deterministic post-condition polling with `TestClock.withLive` instead of relying on real-time delays, ensuring consistent test outcomes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant