diff --git a/CLAUDE.md b/CLAUDE.md index 30d98141df..96472c984f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,6 +131,7 @@ docker-compose up -d - Use `scripts/cargo/check-rivetkit-core-wasm.sh` as the canonical wasm gate for `rivetkit-core`; it checks the wasm build, scans native dependency leaks, and verifies native transport/runtime features fail on wasm. - The high-level `rivetkit` crate stays a thin typed wrapper over `rivetkit-core` and re-exports shared transport/config types instead of redefining them. - When `rivetkit` needs ergonomic helpers on a `rivetkit-core` type it re-exports, prefer an extension trait plus `prelude` re-export instead of wrapping and replacing the core type. +- RivetKit action and event protocol `args` must always be array-shaped before crossing the client protocol boundary. Normalize at the server/source side, not in client delivery code: named structs/objects become `[object]`, tuples/arrays stay positional, scalars become `[scalar]`, and unit/null becomes `[]`. - `engine/sdks/*/api-*` are auto-generated SDK outputs; update the source API schema and regenerate them instead of editing them by hand. ### RivetKit Test Fixtures @@ -336,7 +337,7 @@ When the user asks to track something in a note, store it in `~/.agents/notes/` ### Comments - Write comments as normal, complete sentences. Avoid fragmented structures with parentheticals and dashes like `// Spawn engine (if configured) - regardless of start kind`. Instead, write `// Spawn the engine if configured`. Especially avoid dashes (hyphens are OK). -- Do not use em dashes (—). Use periods to separate sentences instead. +- Never use em dashes (—) in any plain-English writing (docs, comments, PR descriptions, prose). Use periods to separate sentences instead. - Documenting deltas is not important or useful. A developer who has never worked on the project will not gain extra information if you add a comment stating that something was removed or changed because they don't know what was there before. The only time you would be adding a comment for something NOT being there is if its unintuitive for why its not there in the first place. ### Match statements diff --git a/examples/chat-room-effect/.gitignore b/examples/chat-room-effect/.gitignore new file mode 100644 index 0000000000..dc6f607390 --- /dev/null +++ b/examples/chat-room-effect/.gitignore @@ -0,0 +1,2 @@ +.actorcore +node_modules diff --git a/examples/chat-room-effect/README.md b/examples/chat-room-effect/README.md new file mode 100644 index 0000000000..4542c5fdfe --- /dev/null +++ b/examples/chat-room-effect/README.md @@ -0,0 +1,46 @@ +# Chat Room (Effect) + +Example project demonstrating a real-time chat room built with the [Effect](https://effect.website) SDK for Rivet Actors. + +## Getting Started + +```sh +git clone https://github.com/rivet-dev/rivet.git +cd rivet/examples/chat-room-effect +npm install +npm run dev +``` + +In a separate terminal, run a client against the server: + +```sh +npm run client # Effect client +npm run client:raw # plain RivetKit client +``` + +## Features + +- **Effect-native actors**: Define actors with `Actor.make` and implement them with `toLayer`, composing actor logic from Effect `Layer`s and services +- **Typed action protocols**: Actions are standalone `Action.make` values with `effect/Schema` payloads, successes, and errors validated end to end +- **Typed domain errors**: `MemberNotInRoomError` and `BannedWordsError` flow through the action error channel and are caught by tag on the client +- **Actor-to-actor RPC**: The `ChatRoom` actor calls a separate `Moderator` actor to screen messages, using the same client API as client-to-actor calls +- **Persistent state and SQLite**: Room membership lives in persisted actor state while message history is stored in the actor's SQLite database +- **Scheduling**: A welcome message is scheduled after a member joins and dispatched back through the actor's own action + +## Implementation + +The example splits each actor into a public contract and a server-only implementation: + +- **Chat room contract** ([`src/actors/chat-room/api.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/chat-room-effect/src/actors/chat-room/api.ts)): Declares the `ChatRoom` actor, its actions, and its typed errors +- **Chat room implementation** ([`src/actors/chat-room/live.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/chat-room-effect/src/actors/chat-room/live.ts)): Implements the wake scope, state schema, SQLite migration, and action handlers +- **Moderator** ([`src/actors/moderator/api.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/chat-room-effect/src/actors/moderator/api.ts), [`live.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/chat-room-effect/src/actors/moderator/live.ts)): A second actor that screens messages for banned words +- **Server** ([`src/main.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/chat-room-effect/src/main.ts)): Composes the actor layers and serves them with `Registry.serve` +- **Clients** ([`src/client.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/chat-room-effect/src/client.ts), [`src/client-raw.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/chat-room-effect/src/client-raw.ts)): An Effect client using the typed `ChatRoom.client` accessor, and a plain RivetKit client showing the same actors reached from non-Effect code + +## Resources + +Read more about [actions](/docs/actors/actions), [state](/docs/actors/state), [events](/docs/actors/events), and [the Effect quickstart](/docs/actors/quickstart/effect). + +## License + +MIT diff --git a/examples/effect/package.json b/examples/chat-room-effect/package.json similarity index 83% rename from examples/effect/package.json rename to examples/chat-room-effect/package.json index 9610c629cd..ff5346fa0d 100644 --- a/examples/effect/package.json +++ b/examples/chat-room-effect/package.json @@ -1,5 +1,5 @@ { - "name": "example-effect", + "name": "example-chat-room-effect", "private": true, "type": "module", "scripts": { @@ -7,6 +7,7 @@ "start": "RIVET_RUN_ENGINE=1 RIVET_ENGINE_BINARY=../../target/debug/rivet-engine tsx src/main.ts", "client": "tsx src/client.ts", "client:raw": "tsx src/client-raw.ts", + "test": "vitest run", "check-types": "tsc --noEmit" }, "dependencies": { @@ -18,9 +19,11 @@ "rivetkit": "workspace:*" }, "devDependencies": { + "@effect/vitest": "4.0.0-beta.66", "@types/node": "^22.13.9", "tsx": "^4.20.5", - "typescript": "^5.5.2" + "typescript": "^5.5.2", + "vitest": "^4.1.5" }, "template": { "noFrontend": true, diff --git a/examples/effect/src/actors/chat-room/api.ts b/examples/chat-room-effect/src/actors/chat-room/api.ts similarity index 100% rename from examples/effect/src/actors/chat-room/api.ts rename to examples/chat-room-effect/src/actors/chat-room/api.ts diff --git a/examples/effect/src/actors/chat-room/live.ts b/examples/chat-room-effect/src/actors/chat-room/live.ts similarity index 100% rename from examples/effect/src/actors/chat-room/live.ts rename to examples/chat-room-effect/src/actors/chat-room/live.ts diff --git a/examples/effect/src/actors/mod.ts b/examples/chat-room-effect/src/actors/mod.ts similarity index 100% rename from examples/effect/src/actors/mod.ts rename to examples/chat-room-effect/src/actors/mod.ts diff --git a/examples/effect/src/actors/moderator/api.ts b/examples/chat-room-effect/src/actors/moderator/api.ts similarity index 100% rename from examples/effect/src/actors/moderator/api.ts rename to examples/chat-room-effect/src/actors/moderator/api.ts diff --git a/examples/effect/src/actors/moderator/live.ts b/examples/chat-room-effect/src/actors/moderator/live.ts similarity index 100% rename from examples/effect/src/actors/moderator/live.ts rename to examples/chat-room-effect/src/actors/moderator/live.ts diff --git a/examples/effect/src/client-raw.ts b/examples/chat-room-effect/src/client-raw.ts similarity index 100% rename from examples/effect/src/client-raw.ts rename to examples/chat-room-effect/src/client-raw.ts diff --git a/examples/effect/src/client.ts b/examples/chat-room-effect/src/client.ts similarity index 100% rename from examples/effect/src/client.ts rename to examples/chat-room-effect/src/client.ts diff --git a/examples/effect/src/logger.ts b/examples/chat-room-effect/src/logger.ts similarity index 100% rename from examples/effect/src/logger.ts rename to examples/chat-room-effect/src/logger.ts diff --git a/examples/effect/src/main.ts b/examples/chat-room-effect/src/main.ts similarity index 100% rename from examples/effect/src/main.ts rename to examples/chat-room-effect/src/main.ts diff --git a/examples/chat-room-effect/tests/chat-room.test.ts b/examples/chat-room-effect/tests/chat-room.test.ts new file mode 100644 index 0000000000..3c70f979c9 --- /dev/null +++ b/examples/chat-room-effect/tests/chat-room.test.ts @@ -0,0 +1,85 @@ +import { assert, layer } from "@effect/vitest"; +import { Registry } from "@rivetkit/effect"; +import { Effect, Layer, Random } from "effect"; +import { ChatRoom, MemberNotInRoomError } from "../src/actors/chat-room/api.ts"; +import { ChatRoomLive, RoomPolicyLive } from "../src/actors/chat-room/live.ts"; +import { BannedWordsError } from "../src/actors/moderator/api.ts"; +import { ModeratorLive } from "../src/actors/moderator/live.ts"; + +// `Registry.test` boots the actors in-process against a local engine. With no +// endpoint configured on `Registry.layer`, it auto-spawns a `rivet-engine` for +// the duration of the suite, the same way `setupTest` does for the other +// examples. It also provides `Client`, so `ChatRoom.client` resolves here. +const TestLayer = Registry.test.pipe( + Layer.provideMerge( + Layer.mergeAll( + ModeratorLive, + ChatRoomLive.pipe(Layer.provide(RoomPolicyLive)), + ), + ), + Layer.provide(Registry.layer()), +); + +// A fresh room key per test keeps actor state from bleeding across cases. +const freshRoom = Effect.gen(function* () { + const client = yield* ChatRoom.client; + return client.getOrCreate(`chatroom_${yield* Random.nextUUIDv4}`); +}); + +layer(TestLayer)("chat-room-effect", (it) => { + it.effect("joins a room and reads message history", () => + Effect.gen(function* () { + const room = yield* freshRoom; + yield* room.Initialize({ name: "Effect Lovers" }); + + // The room seeds an "Admin" member, so Alice is the second. + const { memberCount } = yield* room.Join({ name: "Alice" }); + assert.strictEqual(memberCount, 2); + + yield* room.SendMessage({ + sender: "Alice", + text: "hello from Effect", + }); + + const history = yield* room.GetHistory(); + assert.strictEqual(history.length, 1); + assert.strictEqual(history[0].sender, "Alice"); + assert.strictEqual(history[0].text, "hello from Effect"); + }), + ); + + it.effect("rejects messages from non-members", () => + Effect.gen(function* () { + const room = yield* freshRoom; + yield* room.Initialize({ name: "Closed Room" }); + + const exit = yield* room + .SendMessage({ sender: "Mallory", text: "let me in" }) + .pipe(Effect.flip, Effect.exit); + + assert.isTrue(exit._tag === "Success"); + if (exit._tag === "Success") { + assert.instanceOf(exit.value, MemberNotInRoomError); + } + }), + ); + + it.effect("rejects banned words through the moderator actor", () => + Effect.gen(function* () { + const room = yield* freshRoom; + yield* room.Initialize({ name: "Moderated Room" }); + yield* room.Join({ name: "Alice" }); + + // The error originates in the Moderator actor and flows back + // through SendMessage's declared error channel. + const exit = yield* room + .SendMessage({ sender: "Alice", text: "this contains spam" }) + .pipe(Effect.flip, Effect.exit); + + assert.isTrue(exit._tag === "Success"); + if (exit._tag === "Success") { + assert.instanceOf(exit.value, BannedWordsError); + } + }), + ); +}); diff --git a/examples/effect/tsconfig.json b/examples/chat-room-effect/tsconfig.json similarity index 88% rename from examples/effect/tsconfig.json rename to examples/chat-room-effect/tsconfig.json index c3382bb665..4e95b1c507 100644 --- a/examples/effect/tsconfig.json +++ b/examples/chat-room-effect/tsconfig.json @@ -11,5 +11,5 @@ "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true }, - "include": ["src/**/*"] + "include": ["src/**/*", "tests/**/*"] } diff --git a/examples/effect/turbo.json b/examples/chat-room-effect/turbo.json similarity index 100% rename from examples/effect/turbo.json rename to examples/chat-room-effect/turbo.json diff --git a/examples/chat-room-effect/vitest.config.ts b/examples/chat-room-effect/vitest.config.ts new file mode 100644 index 0000000000..5bdee00206 --- /dev/null +++ b/examples/chat-room-effect/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/examples/hello-world-effect/.gitignore b/examples/hello-world-effect/.gitignore new file mode 100644 index 0000000000..dc6f607390 --- /dev/null +++ b/examples/hello-world-effect/.gitignore @@ -0,0 +1,2 @@ +.actorcore +node_modules diff --git a/examples/hello-world-effect/README.md b/examples/hello-world-effect/README.md new file mode 100644 index 0000000000..b59937b050 --- /dev/null +++ b/examples/hello-world-effect/README.md @@ -0,0 +1,42 @@ +# Hello World (Effect) + +Minimal counter actor built with the [Effect](https://effect.website) SDK for Rivet Actors. + +## Getting Started + +```sh +git clone https://github.com/rivet-dev/rivet.git +cd rivet/examples/hello-world-effect +npm install +npm run dev +``` + +In a separate terminal, run the client against the server: + +```sh +npm run client +``` + +## Features + +- **Effect-native actors**: Define an actor with `Actor.make` and implement it with `toLayer`, returning action handlers from an Effect wake scope +- **Typed action protocols**: `Increment` and `GetCount` are standalone `Action.make` values with `effect/Schema` payloads and successes validated end to end +- **Persistent state**: The counter value lives in persisted actor state, accessed through a `SubscriptionRef`-like `State` API +- **Events**: Each increment broadcasts the new count to every connected client + +## Implementation + +The actor is split into a public contract and a server-only implementation: + +- **Contract** ([`src/actors/counter/api.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/hello-world-effect/src/actors/counter/api.ts)): Declares the `Counter` actor and its actions +- **Implementation** ([`src/actors/counter/live.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/hello-world-effect/src/actors/counter/live.ts)): Implements the wake scope, state schema, and action handlers +- **Server** ([`src/main.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/hello-world-effect/src/main.ts)): Composes the actor layer and serves it with `Registry.serve` +- **Client** ([`src/client.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/hello-world-effect/src/client.ts)): An Effect client using the typed `Counter.client` accessor + +## Resources + +Read more about [actions](/docs/actors/actions), [state](/docs/actors/state), [events](/docs/actors/events), and [the Effect quickstart](/docs/actors/quickstart/effect). + +## License + +MIT diff --git a/examples/hello-world-effect/package.json b/examples/hello-world-effect/package.json new file mode 100644 index 0000000000..3ab69e1719 --- /dev/null +++ b/examples/hello-world-effect/package.json @@ -0,0 +1,32 @@ +{ + "name": "example-hello-world-effect", + "private": true, + "type": "module", + "scripts": { + "dev": "RIVET_RUN_ENGINE=1 RIVET_ENGINE_BINARY=../../target/debug/rivet-engine tsx watch src/main.ts", + "start": "RIVET_RUN_ENGINE=1 RIVET_ENGINE_BINARY=../../target/debug/rivet-engine tsx src/main.ts", + "client": "tsx src/client.ts", + "test": "vitest run", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@effect/platform-node": "4.0.0-beta.66", + "@rivetkit/effect": "workspace:*", + "effect": "4.0.0-beta.66", + "pino": "9.9.5", + "pino-pretty": "13.1.2", + "rivetkit": "workspace:*" + }, + "devDependencies": { + "@effect/vitest": "4.0.0-beta.66", + "@types/node": "^22.13.9", + "tsx": "^4.20.5", + "typescript": "^5.5.2", + "vitest": "^4.1.5" + }, + "template": { + "noFrontend": true, + "skipVercel": true + }, + "license": "MIT" +} diff --git a/examples/hello-world-effect/src/actors/counter/api.ts b/examples/hello-world-effect/src/actors/counter/api.ts new file mode 100644 index 0000000000..e06e6efdc2 --- /dev/null +++ b/examples/hello-world-effect/src/actors/counter/api.ts @@ -0,0 +1,26 @@ +import { Action, Actor } from "@rivetkit/effect"; +import { Schema } from "effect"; + +// --- Actions --- + +// Actions are standalone values with explicit `effect/Schema` payloads and +// successes. The schemas validate encoded data end to end and control how +// values are encoded on the wire and decoded inside handlers. + +export const Increment = Action.make("Increment", { + payload: { amount: Schema.Number }, + success: Schema.Number, +}); + +export const GetCount = Action.make("GetCount", { + success: Schema.Number, +}); + +// --- Actor Definition --- + +// The definition is the actor's public contract. It carries no implementation +// or server-only configuration, so it can be imported from client code without +// leaking server details. +export const Counter = Actor.make("Counter", { + actions: [Increment, GetCount], +}); diff --git a/examples/hello-world-effect/src/actors/counter/live.ts b/examples/hello-world-effect/src/actors/counter/live.ts new file mode 100644 index 0000000000..d8f3f8b5fa --- /dev/null +++ b/examples/hello-world-effect/src/actors/counter/live.ts @@ -0,0 +1,39 @@ +import { Actor, State } from "@rivetkit/effect"; +import { Effect, Schema } from "effect"; +import { Counter } from "./api.ts"; + +// --- Actor Implementation --- + +// `.toLayer` produces a Layer that registers this actor with the `Registry` +// service in context. The first parameter is a `wake` function that runs once +// when the actor awakes and returns the action handlers. +export const CounterLive = Counter.toLayer( + Effect.fnUntraced(function* ({ rawRivetkitContext, state }) { + return Counter.of({ + Increment: Effect.fnUntraced(function* ({ payload }) { + // Access the actor's persisted `state` with a `SubscriptionRef`-like API. + const next = yield* State.updateAndGet(state, (current) => ({ + count: current.count + payload.amount, + })).pipe(Effect.orDie); + + // Broadcast the new value to every connected client. + rawRivetkitContext.broadcast("newCount", next.count); + + return next.count; + }), + GetCount: () => + State.get(state).pipe( + Effect.map((current) => current.count), + Effect.orDie, + ), + }); + }), + { + state: { + schema: Schema.Struct({ count: Schema.Number }), + initialValue: () => ({ count: 0 }), + }, + name: "Counter", // Human-friendly display name + icon: "calculator", // FontAwesome icon name + }, +); diff --git a/examples/hello-world-effect/src/actors/mod.ts b/examples/hello-world-effect/src/actors/mod.ts new file mode 100644 index 0000000000..c469445d13 --- /dev/null +++ b/examples/hello-world-effect/src/actors/mod.ts @@ -0,0 +1 @@ +export * from "./counter/api.ts"; diff --git a/examples/hello-world-effect/src/client.ts b/examples/hello-world-effect/src/client.ts new file mode 100644 index 0000000000..35d6e9844f --- /dev/null +++ b/examples/hello-world-effect/src/client.ts @@ -0,0 +1,26 @@ +import { NodeRuntime } from "@effect/platform-node"; +import { Client } from "@rivetkit/effect"; +import { Effect } from "effect"; +import { Counter } from "./actors/mod.ts"; +import { PrettyLoggerLayer } from "./logger.ts"; + +const program = Effect.gen(function* () { + // `Actor.client` yields a typed accessor backed by the Effect SDK client layer. + const counterClient = yield* Counter.client; + const counter = counterClient.getOrCreate("hello-world"); + + const first = yield* counter.Increment({ amount: 1 }); + yield* Effect.log(`count is now ${first}`); + + const second = yield* counter.Increment({ amount: 5 }); + yield* Effect.log(`count is now ${second}`); + + const total = yield* counter.GetCount(); + yield* Effect.log(`final count: ${total}`); +}); + +const ClientLayer = Client.layer({ endpoint: "http://127.0.0.1:6420" }); + +program + .pipe(Effect.provide(ClientLayer), Effect.provide(PrettyLoggerLayer)) + .pipe(NodeRuntime.runMain); diff --git a/examples/hello-world-effect/src/logger.ts b/examples/hello-world-effect/src/logger.ts new file mode 100644 index 0000000000..f7cc932806 --- /dev/null +++ b/examples/hello-world-effect/src/logger.ts @@ -0,0 +1,8 @@ +import { Logger } from "@rivetkit/effect"; +import { pino } from "pino"; + +// This layer replaces the default RivetKit Effect logger with a custom Pino +// logger. It affects both Effect.log* calls and the underlying RivetKit logs. +export const PrettyLoggerLayer = Logger.layerPino( + pino({ transport: { target: "pino-pretty" } }), +); diff --git a/examples/hello-world-effect/src/main.ts b/examples/hello-world-effect/src/main.ts new file mode 100644 index 0000000000..afd9a20fb3 --- /dev/null +++ b/examples/hello-world-effect/src/main.ts @@ -0,0 +1,30 @@ +import { NodeRuntime } from "@effect/platform-node"; +import { Client, Registry } from "@rivetkit/effect"; +import { Layer } from "effect"; +import { CounterLive } from "./actors/counter/live.ts"; +import { PrettyLoggerLayer } from "./logger.ts"; + +const endpoint = process.env.RIVET_ENDPOINT ?? "http://127.0.0.1:6420"; + +const ActorsLayer = CounterLive.pipe(Layer.provide(Client.layer({ endpoint }))); + +// Engine config defaults to spawning a local rivet-engine process and +// listening on http://127.0.0.1:6420 (override via RIVET_ENDPOINT to +// point at a remote engine). For dev builds without a packaged engine, +// set RIVET_ENGINE_BINARY to the path of a `cargo build` binary, e.g.: +// RIVET_ENGINE_BINARY=$(pwd)/target/debug/rivet-engine pnpm start +const MainLayer = Registry.serve(ActorsLayer).pipe( + Layer.provide(Registry.layer()), + Layer.provide(PrettyLoggerLayer), +); + +// Keeps the layer alive. Tears down on SIGINT/SIGTERM. +Layer.launch(MainLayer).pipe(NodeRuntime.runMain); + +// Or create a web handler, which can be used in serverless environments. +export const { handler, dispose } = Registry.toWebHandler( + ActorsLayer.pipe( + Layer.provideMerge(Registry.layer()), + Layer.provide(PrettyLoggerLayer), + ), +); diff --git a/examples/hello-world-effect/tests/counter.test.ts b/examples/hello-world-effect/tests/counter.test.ts new file mode 100644 index 0000000000..32d4bbeb81 --- /dev/null +++ b/examples/hello-world-effect/tests/counter.test.ts @@ -0,0 +1,37 @@ +import { assert, layer } from "@effect/vitest"; +import { Registry } from "@rivetkit/effect"; +import { Effect, Layer } from "effect"; +import { Counter } from "../src/actors/counter/api.ts"; +import { CounterLive } from "../src/actors/counter/live.ts"; + +// `Registry.test` boots the actor in-process against a local engine. With no +// endpoint configured on `Registry.layer`, it auto-spawns a `rivet-engine` for +// the duration of the suite, the same way `setupTest` does for the other +// examples. It also provides `Client`, so `Counter.client` resolves here. +const TestLayer = Registry.test.pipe( + Layer.provideMerge(CounterLive), + Layer.provide(Registry.layer()), +); + +layer(TestLayer)("hello-world-effect", (it) => { + it.effect("increments and reads the count back", () => + Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate("t-increment"); + assert.strictEqual(yield* counter.Increment({ amount: 1 }), 1); + assert.strictEqual(yield* counter.Increment({ amount: 5 }), 6); + assert.strictEqual(yield* counter.GetCount(), 6); + }), + ); + + it.effect("isolates state across keys", () => + Effect.gen(function* () { + const client = yield* Counter.client; + const a = client.getOrCreate("t-iso-a"); + const b = client.getOrCreate("t-iso-b"); + yield* a.Increment({ amount: 2 }); + yield* b.Increment({ amount: 7 }); + assert.strictEqual(yield* a.GetCount(), 2); + assert.strictEqual(yield* b.GetCount(), 7); + }), + ); +}); diff --git a/examples/hello-world-effect/tsconfig.json b/examples/hello-world-effect/tsconfig.json new file mode 100644 index 0000000000..4e95b1c507 --- /dev/null +++ b/examples/hello-world-effect/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["node"], + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true + }, + "include": ["src/**/*", "tests/**/*"] +} diff --git a/examples/hello-world-effect/turbo.json b/examples/hello-world-effect/turbo.json new file mode 100644 index 0000000000..29d4cb2625 --- /dev/null +++ b/examples/hello-world-effect/turbo.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"] +} diff --git a/examples/hello-world-effect/vitest.config.ts b/examples/hello-world-effect/vitest.config.ts new file mode 100644 index 0000000000..5bdee00206 --- /dev/null +++ b/examples/hello-world-effect/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b599fc08b..6e6a4add9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -488,6 +488,43 @@ importers: specifier: ^3.1.1 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@22.19.10)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + examples/chat-room-effect: + dependencies: + '@effect/platform-node': + specifier: 4.0.0-beta.66 + version: 4.0.0-beta.66(effect@4.0.0-beta.66)(ioredis@5.10.1) + '@rivetkit/effect': + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/effect + effect: + specifier: 4.0.0-beta.66 + version: 4.0.0-beta.66 + pino: + specifier: 9.9.5 + version: 9.9.5 + pino-pretty: + specifier: 13.1.2 + version: 13.1.2 + rivetkit: + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/rivetkit + devDependencies: + '@effect/vitest': + specifier: 4.0.0-beta.66 + version: 4.0.0-beta.66(effect@4.0.0-beta.66)(vitest@4.1.7) + '@types/node': + specifier: ^22.13.9 + version: 22.19.15 + tsx: + specifier: ^4.20.5 + version: 4.21.0 + typescript: + specifier: ^5.5.2 + version: 5.9.3 + vitest: + specifier: ^4.1.5 + version: 4.1.7(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.7)(msw@2.14.4(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)) + examples/chat-room-render: dependencies: '@hono/node-server': @@ -795,37 +832,6 @@ importers: specifier: ^3.1.1 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@22.19.15)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) - examples/effect: - dependencies: - '@effect/platform-node': - specifier: 4.0.0-beta.66 - version: 4.0.0-beta.66(effect@4.0.0-beta.66)(ioredis@5.10.1) - '@rivetkit/effect': - specifier: workspace:* - version: link:../../rivetkit-typescript/packages/effect - effect: - specifier: 4.0.0-beta.66 - version: 4.0.0-beta.66 - pino: - specifier: 9.9.5 - version: 9.9.5 - pino-pretty: - specifier: 13.1.2 - version: 13.1.2 - rivetkit: - specifier: workspace:* - version: link:../../rivetkit-typescript/packages/rivetkit - devDependencies: - '@types/node': - specifier: ^22.13.9 - version: 22.19.15 - tsx: - specifier: ^4.20.5 - version: 4.21.0 - typescript: - specifier: ^5.5.2 - version: 5.9.3 - examples/elysia: dependencies: elysia: @@ -992,6 +998,43 @@ importers: specifier: ^3.1.1 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(less@4.4.1)(lightningcss@1.32.0)(msw@2.14.4(@types/node@22.19.10)(typescript@5.9.3))(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + examples/hello-world-effect: + dependencies: + '@effect/platform-node': + specifier: 4.0.0-beta.66 + version: 4.0.0-beta.66(effect@4.0.0-beta.66)(ioredis@5.10.1) + '@rivetkit/effect': + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/effect + effect: + specifier: 4.0.0-beta.66 + version: 4.0.0-beta.66 + pino: + specifier: 9.9.5 + version: 9.9.5 + pino-pretty: + specifier: 13.1.2 + version: 13.1.2 + rivetkit: + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/rivetkit + devDependencies: + '@effect/vitest': + specifier: 4.0.0-beta.66 + version: 4.0.0-beta.66(effect@4.0.0-beta.66)(vitest@4.1.7) + '@types/node': + specifier: ^22.13.9 + version: 22.19.15 + tsx: + specifier: ^4.20.5 + version: 4.21.0 + typescript: + specifier: ^5.5.2 + version: 5.9.3 + vitest: + specifier: ^4.1.5 + version: 4.1.7(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.7)(msw@2.14.4(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)) + examples/hello-world-render: dependencies: '@rivetkit/react': @@ -4800,6 +4843,12 @@ packages: effect: ^4.0.0-beta.66 ioredis: ^5.7.0 + '@effect/vitest@4.0.0-beta.66': + resolution: {integrity: sha512-UHPNtU0xXkKtNgyRQEh2c8jh4nIIm8Mzp3xc4j2ZdFU4nq5ZSySnpovjPMdoWbVClg1ki8UbpNGEZUfxEJo+6Q==} + peerDependencies: + effect: ^4.0.0-beta.66 + vitest: ^3.0.0 || ^4.0.0 + '@effect/vitest@4.0.0-beta.70': resolution: {integrity: sha512-XDteNN0xfOgoMauAVoN5iylxVgEjp7kFsGFq18tZ5XYjek0eOZa0nOoes5s7Bs71VvwjnCeCbFMD7IhxswEt8A==} peerDependencies: @@ -13953,9 +14002,6 @@ packages: msgpackr@1.11.12: resolution: {integrity: sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==} - msgpackr@1.11.5: - resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} - msw@2.14.4: resolution: {integrity: sha512-HVPZJ9Rx4nDCWhjNQ57lKQGSE+0zDHw0xWE2IN2rLOUTLkagEBWNlvWuKYNwG2pQWq96TMd8NiSK/6vO1udnWQ==} engines: {node: '>=18'} @@ -19142,6 +19188,11 @@ snapshots: - bufferutil - utf-8-validate + '@effect/vitest@4.0.0-beta.66(effect@4.0.0-beta.66)(vitest@4.1.7)': + dependencies: + effect: 4.0.0-beta.66 + vitest: 4.1.7(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/coverage-v8@4.1.7)(msw@2.14.4(@types/node@22.19.15)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)) + '@effect/vitest@4.0.0-beta.70(effect@4.0.0-beta.66)(vitest@4.1.7)': dependencies: effect: 4.0.0-beta.66 @@ -24796,7 +24847,7 @@ snapshots: '@vitest/spy': 4.0.18 '@vitest/utils': 4.0.18 chai: 6.2.2 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 '@vitest/expect@4.1.7': dependencies: @@ -24871,7 +24922,7 @@ snapshots: '@vitest/pretty-format@4.0.18': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 '@vitest/pretty-format@4.1.7': dependencies: @@ -24973,7 +25024,7 @@ snapshots: '@vitest/utils@4.0.18': dependencies: '@vitest/pretty-format': 4.0.18 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 '@vitest/utils@4.1.7': dependencies: @@ -29199,7 +29250,7 @@ snapshots: lmdb@3.4.4: dependencies: - msgpackr: 1.11.5 + msgpackr: 1.11.12 node-addon-api: 6.1.0 node-gyp-build-optional-packages: 5.2.2 ordered-binary: 1.6.0 @@ -30369,10 +30420,6 @@ snapshots: optionalDependencies: msgpackr-extract: 3.0.3 - msgpackr@1.11.5: - optionalDependencies: - msgpackr-extract: 3.0.3 - msw@2.14.4(@types/node@20.19.13)(typescript@5.9.3): dependencies: '@inquirer/confirm': 6.0.12(@types/node@20.19.13) @@ -30777,7 +30824,7 @@ snapshots: openapi3-ts@4.5.0: dependencies: - yaml: 2.8.2 + yaml: 2.9.0 openapi@1.0.1: dependencies: @@ -31174,7 +31221,7 @@ snapshots: postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@25.0.7)(typescript@5.9.3)): dependencies: lilconfig: 3.1.3 - yaml: 2.8.2 + yaml: 2.9.0 optionalDependencies: postcss: 8.5.6 ts-node: 10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@25.0.7)(typescript@5.9.3) diff --git a/rivetkit-rust/packages/rivetkit-core/src/registry/http.rs b/rivetkit-rust/packages/rivetkit-core/src/registry/http.rs index 8bff175167..9839d79247 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/registry/http.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/registry/http.rs @@ -834,19 +834,13 @@ pub(super) fn decode_http_action_args( HttpResponseEncoding::Json => { let request: HttpActionRequestJson = serde_json::from_slice(body).context("decode json HTTP action request")?; - let args = match request.args { - JsonValue::Array(args) => args, - _ => Vec::new(), - }; + let args = normalize_json_args(request.args); encode_json_as_cbor(&args) } HttpResponseEncoding::Cbor => { let request: HttpActionRequestJson = ciborium::from_reader(Cursor::new(body)) .context("decode cbor HTTP action request")?; - let args = match request.args { - JsonValue::Array(args) => args, - _ => Vec::new(), - }; + let args = normalize_json_args(request.args); encode_json_as_cbor(&args) } HttpResponseEncoding::Bare => { @@ -858,6 +852,14 @@ pub(super) fn decode_http_action_args( } } +fn normalize_json_args(args: JsonValue) -> Vec { + match args { + JsonValue::Array(args) => args, + JsonValue::Null => Vec::new(), + value => vec![value], + } +} + pub(super) fn decode_http_queue_request( encoding: HttpResponseEncoding, body: &[u8], diff --git a/rivetkit-rust/packages/rivetkit-core/src/registry/inspector.rs b/rivetkit-rust/packages/rivetkit-core/src/registry/inspector.rs index d591b5594f..448cdd09ec 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/registry/inspector.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/registry/inspector.rs @@ -144,8 +144,33 @@ impl RegistryDispatcher { Ok(body) => body, Err(response) => return Ok(Some(response)), }; + if !body.args.is_empty() && body.properties.is_some() { + return Ok(Some(json_http_response( + StatusCode::BAD_REQUEST, + &json!({ + "error": "use either args or properties, not both", + }), + )?)); + } + if body + .properties + .as_ref() + .is_some_and(|properties| !properties.is_object()) + { + return Ok(Some(json_http_response( + StatusCode::BAD_REQUEST, + &json!({ + "error": "properties must be an object", + }), + )?)); + } + let args = if let Some(properties) = body.properties { + vec![properties] + } else { + body.args + }; match self - .execute_inspector_action(instance, &action_name, body.args) + .execute_inspector_action(instance, &action_name, args) .await { Ok(output) => json_http_response( diff --git a/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs b/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs index d7b76a0c21..3f1581e787 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/registry/mod.rs @@ -251,6 +251,7 @@ struct InspectorPatchStateBody { #[serde(default)] struct InspectorActionBody { args: Vec, + properties: Option, } #[derive(Debug, Default, Deserialize)] diff --git a/rivetkit-rust/packages/rivetkit/src/action.rs b/rivetkit-rust/packages/rivetkit/src/action.rs index 96035714fa..80c5389fc9 100644 --- a/rivetkit-rust/packages/rivetkit/src/action.rs +++ b/rivetkit-rust/packages/rivetkit/src/action.rs @@ -21,12 +21,17 @@ pub trait Action: serde::Serialize + DeserializeOwned + Send + Sync + 'static { } pub fn encode_positional(value: &T) -> Result> { + encode_varargs(value, "action args") +} + +pub(crate) fn encode_varargs(value: &T, label: &str) -> Result> { let mut encoded = Vec::new(); - ciborium::into_writer(value, &mut encoded).context("encode action args as cbor")?; + ciborium::into_writer(value, &mut encoded) + .with_context(|| format!("encode {label} as cbor"))?; let value: Value = ciborium::from_reader(Cursor::new(&encoded)) - .context("decode action args into cbor value")?; + .with_context(|| format!("decode {label} into cbor value"))?; let value = positional_value(value); - encode_value(&value) + encode_value(&value, label) } pub fn decode_positional(args: &[u8]) -> Result { @@ -54,16 +59,17 @@ pub fn decode_positional(args: &[u8]) -> Result { fn positional_value(value: Value) -> Value { match value { - Value::Map(entries) => Value::Array(entries.into_iter().map(|(_, value)| value).collect()), + Value::Map(_) => Value::Array(vec![value]), Value::Array(values) => Value::Array(values), Value::Null => Value::Array(Vec::new()), value => Value::Array(vec![value]), } } -fn encode_value(value: &Value) -> Result> { +fn encode_value(value: &Value, label: &str) -> Result> { let mut encoded = Vec::new(); - ciborium::into_writer(value, &mut encoded).context("encode positional action args as cbor")?; + ciborium::into_writer(value, &mut encoded) + .with_context(|| format!("encode positional {label} as cbor"))?; Ok(encoded) } @@ -339,14 +345,17 @@ mod tests { } #[test] - fn positional_encode_has_ts_byte_parity() { + fn positional_encode_matches_ts_action_args() { assert_eq!( encode_positional(&NamedArgs { first: "a".into(), second: "b".into(), }) .expect("encode named args"), - vec![0x82, 0x61, b'a', 0x61, b'b'] + vec![ + 0x81, 0xa2, 0x65, b'f', b'i', b'r', b's', b't', 0x61, b'a', 0x66, b's', b'e', b'c', + b'o', b'n', b'd', 0x61, b'b', + ] ); assert_eq!( encode_positional(&NewtypeArg(5)).expect("encode newtype arg"), @@ -410,6 +419,13 @@ mod tests { })) .expect("decode named args from map"); assert_eq!(from_map, from_seq); + + let from_single_map_arg = decode_positional::(&cbor(&vec![NamedArgs { + first: "a".into(), + second: "b".into(), + }])) + .expect("decode named args from single object arg"); + assert_eq!(from_single_map_arg, from_seq); } #[test] @@ -425,7 +441,7 @@ mod tests { } #[test] - fn positional_encode_leaves_nested_struct_as_map() { + fn positional_encode_wraps_named_struct_as_single_arg() { let bytes = encode_positional(&WithNested { nested: Nested { value: 7, @@ -440,9 +456,11 @@ mod tests { let ciborium::Value::Array(values) = value else { panic!("top-level args should be an array"); }; - assert_eq!(values.len(), 2); - assert!(matches!(values[0], ciborium::Value::Map(_))); - assert!(matches!(values[1], ciborium::Value::Bool(true))); + assert_eq!(values.len(), 1); + let ciborium::Value::Map(fields) = &values[0] else { + panic!("named struct arg should remain a map"); + }; + assert_eq!(fields.len(), 2); } fn cbor(value: &T) -> Vec { diff --git a/rivetkit-rust/packages/rivetkit/src/context.rs b/rivetkit-rust/packages/rivetkit/src/context.rs index a94fa65f44..25e4f0e514 100644 --- a/rivetkit-rust/packages/rivetkit/src/context.rs +++ b/rivetkit-rust/packages/rivetkit/src/context.rs @@ -21,6 +21,7 @@ use rivetkit_core::{ use serde::{Serialize, de::DeserializeOwned}; use tokio_util::sync::CancellationToken; +use crate::action; use crate::actor::Actor; use crate::event::Event; use crate::queue::Queue; @@ -345,7 +346,7 @@ impl Ctx { } pub fn broadcast(&self, name: &str, event: &E) -> Result<()> { - let event_bytes = encode_cbor(event, "broadcast event")?; + let event_bytes = action::encode_varargs(event, "event args")?; self.inner.broadcast(name, &event_bytes); Ok(()) } @@ -514,7 +515,7 @@ impl ConnCtx { } pub fn send(&self, name: &str, event: &E) -> Result<()> { - let event_bytes = encode_cbor(event, "connection event")?; + let event_bytes = action::encode_varargs(event, "connection event args")?; self.inner.send(name, &event_bytes); Ok(()) } diff --git a/rivetkit-rust/packages/rivetkit/src/typed_client.rs b/rivetkit-rust/packages/rivetkit/src/typed_client.rs index eb47713f47..4c20d3dd25 100644 --- a/rivetkit-rust/packages/rivetkit/src/typed_client.rs +++ b/rivetkit-rust/packages/rivetkit/src/typed_client.rs @@ -224,8 +224,31 @@ pub(crate) fn encode_action_args(action: &M) -> Result } fn decode_event(event: &ClientEvent) -> Result { - ciborium::from_reader(Cursor::new(&event.raw_args)) - .with_context(|| format!("decode typed event '{}'", E::NAME)) + decode_event_args(&event.raw_args).with_context(|| format!("decode typed event '{}'", E::NAME)) +} + +fn decode_event_args(raw_args: &[u8]) -> Result { + let value: CborValue = + ciborium::from_reader(Cursor::new(raw_args)).context("decode typed event args as cbor")?; + match value { + CborValue::Array(values) if values.is_empty() => { + crate::event::deserialize_cbor_value(CborValue::Null) + .map_err(|error| anyhow::anyhow!(error.to_string())) + .context("decode typed event from empty args") + } + CborValue::Array(mut values) if values.len() == 1 => { + let value = values.remove(0); + crate::event::deserialize_cbor_value(value) + .map_err(|error| anyhow::anyhow!(error.to_string())) + .context("decode typed event from single arg") + } + CborValue::Array(values) => crate::event::deserialize_cbor_value(CborValue::Array(values)) + .map_err(|error| anyhow::anyhow!(error.to_string())) + .context("decode typed event from positional args"), + value => crate::event::deserialize_cbor_value(value) + .map_err(|error| anyhow::anyhow!(error.to_string())) + .context("decode typed event from legacy payload"), + } } fn cbor_to_json(value: CborValue) -> Result { diff --git a/rivetkit-rust/packages/rivetkit/tests/client.rs b/rivetkit-rust/packages/rivetkit/tests/client.rs index f75f5bc3e1..1f1d4bdac7 100644 --- a/rivetkit-rust/packages/rivetkit/tests/client.rs +++ b/rivetkit-rust/packages/rivetkit/tests/client.rs @@ -293,10 +293,10 @@ async fn typed_event_connection(mut socket: WebSocket) { socket .send(connection_message(wire::ToClientBody::Event(wire::Event { name: "notice".to_owned(), - args: cbor(&SiblingNotice { + args: cbor(&vec![SiblingNotice { message: "typed-event".to_owned(), count: 7, - }), + }]), }))) .await .unwrap(); diff --git a/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs b/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs index 96678fc889..b0ce74520b 100644 --- a/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs +++ b/rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs @@ -239,7 +239,8 @@ impl From for ActorConfigInput { .map(|action| rivetkit_core::ActionDefinition { name: action.name }) .collect() }), - // The wasm runtime does not expose custom inspector tabs yet. + // Custom inspector tabs serve assets from a filesystem `root`, which is a + // native/server feature that has no meaning in a browser wasm host. inspector_tabs: None, } } diff --git a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts index fa2dddf8a9..a0a9e2af83 100644 --- a/rivetkit-typescript/packages/rivetkit/src/registry/native.ts +++ b/rivetkit-typescript/packages/rivetkit/src/registry/native.ts @@ -630,6 +630,14 @@ function encodeValue(value: unknown): RuntimeBytes { return encodeCborCompat(value as JsonCompatValue); } +function normalizeArgs(value: unknown): unknown[] { + return Array.isArray(value) + ? value + : value === undefined || value === null + ? [] + : [value]; +} + function unwrapTsfnPayload(error: unknown, payload: T): T { if (error !== null && error !== undefined) { throw error; @@ -1096,11 +1104,7 @@ function wrapNativeCallback, Result>( function decodeArgs(value?: RuntimeBytes | null): unknown[] { const decoded = decodeValue(value); - return Array.isArray(decoded) - ? decoded - : decoded === undefined - ? [] - : [decoded]; + return normalizeArgs(decoded); } function buildRequest(init: { @@ -3837,14 +3841,38 @@ export function buildNativeFactory( 404, ); } - const body = (await jsRequest.json()) as { args?: unknown[] }; + const body = (await jsRequest.json()) as { + args?: unknown; + properties?: unknown; + }; + if (body.args !== undefined && body.properties !== undefined) { + return jsonResponse( + { error: "use either args or properties, not both" }, + { status: 400 }, + ); + } + if ( + body.properties !== undefined && + (body.properties === null || + typeof body.properties !== "object" || + Array.isArray(body.properties)) + ) { + return jsonResponse( + { error: "properties must be an object" }, + { status: 400 }, + ); + } + const args = + body.properties !== undefined + ? [body.properties] + : normalizeArgs(body.args); try { const output = await action( actorCtx, ...validateActionArgs( schemaConfig.actionInputSchemas, actionName, - body.args ?? [], + args, ), ); return jsonResponse({ output }); diff --git a/website/src/content/docs/actors/access-control.mdx b/website/src/content/docs/actors/access-control.mdx index 25ad89d72e..6e272dacb1 100644 --- a/website/src/content/docs/actors/access-control.mdx +++ b/website/src/content/docs/actors/access-control.mdx @@ -126,6 +126,6 @@ Returning `undefined`, `null`, or any non-boolean throws an internal error. ## Notes - `canPublish` only applies to queue names defined in `queues`. -- Incoming queue messages for undefined queues are ignored and logged as warnings. +- Incoming queue messages for undefined queues are ignored and the publish succeeds as completed. - `canSubscribe` only applies to event names defined in `events`. -- Broadcasting an event not defined in `events` logs a warning but still publishes. +- Broadcasting an event not defined in `events` still publishes to subscribers. diff --git a/website/src/content/docs/actors/actions.mdx b/website/src/content/docs/actors/actions.mdx index 0fad4251c8..f53d2529b9 100644 --- a/website/src/content/docs/actors/actions.mdx +++ b/website/src/content/docs/actors/actions.mdx @@ -388,7 +388,6 @@ See [types](/docs/actors/types) for more details on using `ActionContextOf` and - `GET /inspector/rpcs` lists all available actions on an actor. - `POST /inspector/action/:name` executes an action with JSON args and returns output. -- `GET /inspector/traces` helps inspect action timings and failures. - In non-dev mode, inspector endpoints require authorization. ## API Reference diff --git a/website/src/content/docs/actors/ai-and-user-generated-actors.mdx b/website/src/content/docs/actors/ai-and-user-generated-actors.mdx deleted file mode 100644 index e0385745b2..0000000000 --- a/website/src/content/docs/actors/ai-and-user-generated-actors.mdx +++ /dev/null @@ -1,301 +0,0 @@ ---- -title: "AI and User-Generated Rivet Actors" -description: "This guide shows you how to programmatically create sandboxed Rivet environments and deploy custom actor code to them." -skill: true ---- - -import { faGithub } from "@rivet-gg/icons"; - - - - - -Complete example showing how to deploy user-generated Rivet Actor code. - - - -## Use Cases - -Deploying AI and user-generated Rivet Actors to sandboxed namespaces is useful for: - -- **AI-generated code deployments**: Deploy code generated by LLMs in sandboxed environments -- **User sandbox environments**: Give users their own sandboxed Rivet namespace to experiment -- **Preview deployments**: Create ephemeral environments for testing pull requests -- **Multi-tenant applications**: Isolate each customer in their own sandboxed namespace - -## Rivet Actors For AI-Generated Backends - -Traditional architectures require AI agents to coordinate across multiple disconnected systems: a database schemas, API logic, and synchronizing schemas & APIs. - -With Rivet Actors, **state and logic live together in a single actor definition**. This consolidation means: - -- **Less LLM context required**: No need to understand multiple systems or keep them in sync -- **Fewer errors**: State and behavior can't drift apart when they're defined together -- **More powerful generation**: AI agents can focus on business logic instead of infrastructure plumbing - -## How It Works - -The deployment process involves four key steps: - -1. **Create sandboxed Rivet namespace**: Programmatically create a sandboxed Rivet namespace using the Cloud API or self-hosted Rivet API -2. **Generate tokens**: Create the necessary tokens for authentication: - - **Runner token**: Authenticates the serverless runner to execute actors - - **Publishable token**: Used by frontend clients to connect to actors - - **Access token**: Provides API access for configuring the namespace -3. **Deploy AI or user-generated code**: Deploy the actor code and frontend programmatically to your serverless platform of choice (such as Vercel, Netlify, AWS Lambda, or any other provider). We'll be using [Freestyle](https://freestyle.sh) for this example since it's built for this use case. -4. **Connect Rivet to your deployed code**: Configure Rivet to run actors on your deployment in your sandboxed namespace - -## Setup - - - - - - Before you begin, ensure you have: - - Node.js 18+ installed - - A [Freestyle](https://freestyle.sh) account and API token - - A [Rivet Cloud](https://dashboard.rivet.dev/) account - - - - 1. Visit your project on [Rivet Cloud](https://dashboard.rivet.dev/) - 2. Click on "Tokens" in the sidebar - 3. Under "Cloud API Tokens" click "Create Token" - 4. Copy the token for use in your deployment script - - - - Install the required dependencies: - - ```bash - npm install @rivetkit/engine-api-full@^25.7.2 freestyle-sandboxes@^0.0.95 - ``` - - - - Write deployment code that handles namespace creation, token generation, Freestyle deployment, and runner configuration. This can be called from your backend to deploy actor and frontend code to an isolated Rivet namespace. - - ```typescript - import { execSync } from "child_process"; - import { RivetClient } from "@rivetkit/engine-api-full"; - import { FreestyleSandboxes } from "freestyle-sandboxes"; - import { prepareDirForDeploymentSync } from "freestyle-sandboxes/utils"; - - const CLOUD_API_TOKEN = "your-cloud-api-token"; - const FREESTYLE_DOMAIN = "your-app.style.dev"; - const FREESTYLE_API_KEY = "your-freestyle-api-key"; - - async function deploy(projectDir: string) { - // Step 1: Inspect API token to get project and organization - const { project, organization } = await cloudRequest("GET", "/tokens/api/inspect"); - - // Step 2: Create sandboxed namespace with a unique name - const namespaceName = `ns-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; - - const { namespace } = await cloudRequest( - "POST", - `/projects/${project}/namespaces?org=${organization}`, - { displayName: namespaceName.substring(0, 16) }, - ); - const engineNamespaceName = namespace.access.engineNamespaceName; // NOTE: Intentionally different than namespace.name - - // Step 3: Generate tokens - // - Runner token: authenticates the serverless runner to execute actors - // - Publishable token: used by frontend clients to connect to actors - // - Access token: provides API access for configuring the namespace - const { token: runnerToken } = await cloudRequest( - "POST", - `/projects/${project}/namespaces/${namespace.name}/tokens/secret?org=${organization}`, - ); - - const { token: publishableToken } = await cloudRequest( - "POST", - `/projects/${project}/namespaces/${namespace.name}/tokens/publishable?org=${organization}`, - ); - - const { token: accessToken } = await cloudRequest( - "POST", - `/projects/${project}/namespaces/${namespace.name}/tokens/access?org=${organization}`, - ); - - // Step 4: Build the frontend with public environment variables. - execSync("npm run build", { - cwd: projectDir, - env: { - ...process.env, - VITE_RIVET_ENDPOINT: "https://api.rivet.dev", - VITE_RIVET_NAMESPACE: engineNamespaceName, - VITE_RIVET_TOKEN: publishableToken, - }, - stdio: "inherit", - }); - - // Step 5: Deploy actor code and frontend to Freestyle with backend - // environment variables. - const freestyle = new FreestyleSandboxes({ apiKey: FREESTYLE_API_KEY }); - const deploymentSource = prepareDirForDeploymentSync(projectDir); - - const { deploymentId } = await freestyle.deployWeb(deploymentSource, { - envVars: { - RIVET_ENDPOINT: "https://api.rivet.dev", - RIVET_NAMESPACE: engineNamespaceName, - RIVET_TOKEN: runnerToken, - }, - entrypoint: "src/backend/server.ts", - domains: [FREESTYLE_DOMAIN], - build: false, - }); - - // Step 6: Configure Rivet to run actors on the Freestyle deployment. - const rivet = new RivetClient({ - environment: "https://api.rivet.dev", - token: accessToken, - }); - - await rivet.runnerConfigsUpsert("default", { - datacenters: { - "us-west-1": { // Freestyle datacenter is on west coast - serverless: { - url: `https://${FREESTYLE_DOMAIN}/api/rivet`, - headers: {}, - runnersMargin: 0, - minRunners: 0, - maxRunners: 1000, - slotsPerRunner: 1, - requestLifespan: 60 * 5, - }, - }, - }, - namespace: engineNamespaceName, - }); - - console.log("Deployment complete!"); - console.log("Frontend:", `https://${FREESTYLE_DOMAIN}`); - console.log("Rivet Dashboard:", `https://dashboard.rivet.dev/orgs/${organization}/projects/${project}/ns/${namespace.name}`); - console.log("Freestyle Dashboard:", `https://admin.freestyle.sh/dashboard/deployments/${deploymentId}`); - } - - async function cloudRequest(method: string, path: string, body?: any) { - const res = await fetch(`https://api-cloud.rivet.dev${path}`, { - method, - headers: { - Authorization: `Bearer ${CLOUD_API_TOKEN}`, - ...(body && { "Content-Type": "application/json" }), - }, - ...(body && { body: JSON.stringify(body) }), - }); - return res.json(); - } - ``` - - See the [example repository](https://github.com/rivet-dev/rivet/tree/main/examples/ai-and-user-generated-actors-freestyle) for the complete project structure including the template directory and build process. - - For more information on Freestyle deployment, see the [Freestyle documentation](https://docs.freestyle.sh/web/overview). - - - - - - - - Before you begin, ensure you have: - - Node.js 18+ installed - - A [Freestyle](https://freestyle.sh) account and API key - - A [self-hosted Rivet instance](/docs/self-hosting) with endpoint and API token - - - - Install the required dependencies: - - ```bash - npm install @rivetkit/engine-api-full@^25.7.2 freestyle-sandboxes@^0.0.95 - ``` - - - - Write deployment code that handles namespace creation, Freestyle deployment, and runner configuration. This can be called from your backend to deploy actor and frontend code to an isolated Rivet namespace. - - ```typescript - import { execSync } from "child_process"; - import { RivetClient } from "@rivetkit/engine-api-full"; - import { FreestyleSandboxes } from "freestyle-sandboxes"; - import { prepareDirForDeploymentSync } from "freestyle-sandboxes/utils"; - - // Configuration - const RIVET_ENDPOINT = "http://your-rivet-instance:6420"; - const RIVET_TOKEN = "your-rivet-token"; - const FREESTYLE_DOMAIN = "your-app.style.dev"; - const FREESTYLE_API_KEY = "your-freestyle-api-key"; - - async function deploy(projectDir: string) { - // Step 1: Create sandboxed namespace using the self-hosted Rivet API - const rivet = new RivetClient({ - environment: RIVET_ENDPOINT, - token: RIVET_TOKEN, - }); - - const namespaceName = `ns-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; - - const { namespace } = await rivet.namespaces.create({ - displayName: namespaceName, - name: namespaceName, - }); - - // Step 2: Build the frontend with public environment variables. - execSync("npm run build", { - cwd: projectDir, - env: { - ...process.env, - VITE_RIVET_ENDPOINT: RIVET_ENDPOINT, - VITE_RIVET_NAMESPACE: namespace.name, - VITE_RIVET_TOKEN: RIVET_TOKEN, - }, - stdio: "inherit", - }); - - // Step 3: Deploy actor and frontend to Freestyle with backend - // environment variables. - const freestyle = new FreestyleSandboxes({ apiKey: FREESTYLE_API_KEY }); - const deploymentSource = prepareDirForDeploymentSync(projectDir); - - const { deploymentId } = await freestyle.deployWeb(deploymentSource, { - envVars: { - RIVET_ENDPOINT, - RIVET_NAMESPACE: namespace.name, - RIVET_TOKEN, - }, - entrypoint: "src/backend/server.ts", - domains: [FREESTYLE_DOMAIN], - build: false, - }); - - // Step 4: Configure your self-hosted Rivet to run actors on the Freestyle - // deployment - await rivet.runnerConfigsUpsert("default", { - datacenters: { - "us-west-1": { // Freestyle datacenter is on west coast - serverless: { - url: `https://${FREESTYLE_DOMAIN}/api/rivet`, - headers: {}, - runnersMargin: 0, - minRunners: 0, - maxRunners: 1000, - slotsPerRunner: 1, - requestLifespan: 60 * 5, - }, - }, - }, - namespace: namespace.name, - }); - - console.log("Deployment complete!"); - console.log("Frontend:", `https://${FREESTYLE_DOMAIN}`); - console.log("Freestyle Dashboard:", `https://admin.freestyle.sh/dashboard/deployments/${deploymentId}`); - } - ``` - - See the [example repository](https://github.com/rivet-dev/rivet/tree/main/examples/ai-and-user-generated-actors-freestyle) for the complete project structure including the template directory and build process. - - - - diff --git a/website/src/content/docs/actors/appearance.mdx b/website/src/content/docs/actors/appearance.mdx index 98cae507bf..e6bf8089ea 100644 --- a/website/src/content/docs/actors/appearance.mdx +++ b/website/src/content/docs/actors/appearance.mdx @@ -151,10 +151,12 @@ const myCustomRunHandler = (_options: Record) => ({ const myActor = actor({ run: myCustomRunHandler({ /* options */ }), - // Automatically gets "My Custom Handler" name and "bolt" icon + // Picks up "My Custom Handler" name and "bolt" icon in registry metadata }); ``` +This run-handler metadata is currently applied through the registry and serverless metadata paths. The native runtime and inspector config read the actor's `options.name` and `options.icon` directly, so set those explicitly if you need the name or icon to appear everywhere. + Actor-level `options.name` and `options.icon` always take precedence, allowing users to override library defaults: ```typescript diff --git a/website/src/content/docs/actors/authentication.mdx b/website/src/content/docs/actors/authentication.mdx index 4c1b41a153..994c18a18e 100644 --- a/website/src/content/docs/actors/authentication.mdx +++ b/website/src/content/docs/actors/authentication.mdx @@ -235,7 +235,7 @@ function showError(message: string) { } const conn = actorHandle.connect(); -conn.on("error", (error: ActorError) => { +conn.onError((error: ActorError) => { if (error.code === "forbidden") { window.location.href = "/login"; } else if (error.code === "insufficient_permissions") { @@ -596,5 +596,5 @@ const cachedAuthActor = actor({ ## API Reference - [`AuthIntent`](/typedoc/types/rivetkit.mod.AuthIntent.html) - Authentication intent type -- [`BeforeConnectContext`](/typedoc/interfaces/rivetkit.mod.BeforeConnectContext.html) - Context for auth checks -- [`ConnectContext`](/typedoc/interfaces/rivetkit.mod.ConnectContext.html) - Context after connection +- [`OnBeforeConnectContext`](/typedoc/interfaces/rivetkit.mod.OnBeforeConnectContext.html) - Context for auth checks +- [`OnConnectContext`](/typedoc/interfaces/rivetkit.mod.OnConnectContext.html) - Context after connection diff --git a/website/src/content/docs/actors/connections.mdx b/website/src/content/docs/actors/connections.mdx index 5aff25a2e7..00d6d6b0f0 100644 --- a/website/src/content/docs/actors/connections.mdx +++ b/website/src/content/docs/actors/connections.mdx @@ -201,7 +201,7 @@ Connections are not visible in `c.conns` until `createConnState` completes succe ### `onBeforeConnect` -[API Reference](/typedoc/interfaces/rivetkit.mod.BeforeConnectContext.html) +[API Reference](/typedoc/interfaces/rivetkit.mod.OnBeforeConnectContext.html) The `onBeforeConnect` hook is called whenever a new client connects to the actor. Can be async. Clients can pass parameters when connecting, accessible via `params`. This hook is used for connection validation and can throw errors to reject connections. @@ -265,7 +265,7 @@ Connections cannot interact with the actor until this method completes successfu ### `onConnect` -[API Reference](/typedoc/interfaces/rivetkit.mod.ConnectContext.html) +[API Reference](/typedoc/interfaces/rivetkit.mod.OnConnectContext.html) Executed after the client has successfully connected. Can be async. Receives the connection object as a second parameter. @@ -355,7 +355,7 @@ const chatRoom = actor({ ## Connection List -All active connections can be accessed through the context object's `conns` property. This is an array of all current connections. +All active connections can be accessed through the context object's `conns` property. This is a `Map` of all current connections, keyed by connection ID. This is frequently used with `conn.send(name, event)` to send messages directly to clients. To send an event to all connections at once, use `c.broadcast()` instead. See [Events](/docs/actors/events) for more details on broadcasting. @@ -457,6 +457,6 @@ This ensures the underlying network connections close cleanly before continuing. - [`Conn`](/typedoc/interfaces/rivetkit.mod.Conn.html) - Connection interface - [`ConnInitContext`](/typedoc/interfaces/rivetkit.mod.ConnInitContext.html) - Connection initialization context - [`CreateConnStateContext`](/typedoc/interfaces/rivetkit.mod.CreateConnStateContext.html) - Context for creating connection state -- [`BeforeConnectContext`](/typedoc/interfaces/rivetkit.mod.BeforeConnectContext.html) - Pre-connection lifecycle hook context -- [`ConnectContext`](/typedoc/interfaces/rivetkit.mod.ConnectContext.html) - Post-connection lifecycle hook context +- [`OnBeforeConnectContext`](/typedoc/interfaces/rivetkit.mod.OnBeforeConnectContext.html) - Pre-connection lifecycle hook context +- [`OnConnectContext`](/typedoc/interfaces/rivetkit.mod.OnConnectContext.html) - Post-connection lifecycle hook context - [`ActorConn`](/typedoc/types/rivetkit.client_mod.ActorConn.html) - Typed connection from client side diff --git a/website/src/content/docs/actors/debugging.mdx b/website/src/content/docs/actors/debugging.mdx index 08a549ab4d..141baafb2b 100644 --- a/website/src/content/docs/actors/debugging.mdx +++ b/website/src/content/docs/actors/debugging.mdx @@ -34,13 +34,13 @@ The management API runs on the manager base path (default root path) and is used |---|---| | **Local development** | No authentication required. All endpoints are accessible without tokens. | | **Self-hosted engine** | Set `RIVET_TOKEN` to enable authenticated access to restricted endpoints like KV. | -| **Rivet Cloud** | Authentication is enforced by your deployment entrypoint. For manager KV access, use the manager token header below when enabled. | +| **Rivet Cloud** | Authentication is enforced by your deployment entrypoint. For manager KV access, use the bearer token header below when enabled. | -Restricted endpoints (like KV reads) require the `x-rivet-token` header when `RIVET_TOKEN` is configured: +Restricted endpoints (like KV reads) require the `Authorization: Bearer` header when `RIVET_TOKEN` is configured: ```bash curl "$RIVET_API/actors/{actor_id}/kv/keys/{base64_key}" \ - -H "x-rivet-token: $RIVET_TOKEN" + -H "Authorization: Bearer $RIVET_TOKEN" ``` ### List Actors @@ -128,7 +128,7 @@ Requires authentication (see above). ```bash curl "$RIVET_API/actors/{actor_id}/kv/keys/{base64_key}" \ - -H "x-rivet-token: $RIVET_TOKEN" + -H "Authorization: Bearer $RIVET_TOKEN" ``` Returns the value stored at the given key. @@ -271,9 +271,11 @@ Standard actor endpoints (health, actions, requests) and inspector endpoints hav Each actor generates a unique inspector token on first start and persists it in its internal KV store at key `0x03` (base64 `Aw==`). Pass it as a bearer token in the `Authorization` header. +Inspector endpoints always require the actor's inspector token, including in local development. There is no local-development bypass. + | Environment | Authentication | |---|---| -| **Local development** | No authentication required. | +| **Local development** | Bearer the actor's inspector token in the `Authorization` header. Fetch it through the management KV endpoint (see below). | | **Self-hosted engine** | Bearer the actor's inspector token in the `Authorization` header. The Rivet dashboard fetches it automatically; for direct API access, fetch it through the management KV endpoint (see below). | | **Rivet Cloud** | Bearer the actor's inspector token in the `Authorization` header. The Rivet dashboard fetches it automatically; for direct API access, fetch it through the management KV endpoint (see below). | @@ -282,9 +284,9 @@ curl "$RIVET_API/gateway/{actor_id}/inspector/summary" \ -H 'Authorization: Bearer YOUR_INSPECTOR_TOKEN' ``` -#### Retrieving the Inspector Token (Rivet Cloud) +#### Retrieving the Inspector Token -In Rivet Cloud, each actor generates a unique inspector token on first start and persists it in its internal KV store. The Rivet dashboard retrieves this token automatically, but if you need it for direct API access, fetch it from the management KV endpoint. +Each actor generates a unique inspector token on first start and persists it in its internal KV store. The Rivet dashboard retrieves this token automatically, but if you need it for direct API access, fetch it from the management KV endpoint. This applies in every environment, including local development. The inspector token is stored at internal KV key `0x03` (base64: `Aw==`). The response value is also base64-encoded. @@ -293,7 +295,7 @@ The inspector token is stored at internal KV key `0x03` (base64: `Aw==`). The re ACTOR_ID="your-actor-id" RESPONSE=$(curl -s "$RIVET_API/actors/$ACTOR_ID/kv/keys/Aw==" \ - -H "x-rivet-token: $RIVET_TOKEN") + -H "Authorization: Bearer $RIVET_TOKEN") # Extract and decode the base64 value INSPECTOR_TOKEN=$(echo "$RESPONSE" | jq -r '.value' | base64 -d) @@ -319,11 +321,6 @@ curl -X POST $RIVET_API/gateway/{actor_id}/action/myAction \ -H 'Content-Type: application/json' \ -d '{"args": [1, 2, 3]}' -# Send queue message (body includes queue name) -curl -X POST $RIVET_API/gateway/{actor_id}/queue \ - -H 'Content-Type: application/json' \ - -d '{"name":"jobs","body":{"id":"job-1"}}' - # Send queue message (queue name in path) curl -X POST $RIVET_API/gateway/{actor_id}/queue/jobs \ -H 'Content-Type: application/json' \ @@ -338,10 +335,16 @@ curl -X POST $RIVET_API/gateway/{actor_id}/queue/jobs \ curl $RIVET_API/gateway/{actor_id}/request/my/custom/path ``` -Queue send responses include: +Queue send responses always include a `status` field: + +```json +{ "status": "completed" } +``` + +The `response` field is only present when the queue handler returns a value: ```json -{ "status": "completed", "response": null } +{ "status": "completed", "response": { "result": "ok" } } ``` If `wait: true` and the timeout is reached, `status` is `"timedOut"`. @@ -350,6 +353,8 @@ If `wait: true` and the timeout is reached, `status` is `"timedOut"`. The inspector HTTP API exposes JSON endpoints for querying and modifying actor internals at runtime. These are designed for agent-based debugging and tooling. +Every inspector endpoint requires the actor's inspector token as a bearer token, including in local development. The examples below omit the `Authorization` header for brevity, but you must add `-H "Authorization: Bearer $INSPECTOR_TOKEN"` to each request. See [Retrieving the Inspector Token](#retrieving-the-inspector-token) above. + #### Get State ```bash @@ -451,40 +456,6 @@ Returns queue status with messages: } ``` -#### Get Traces - -Query trace spans in OTLP JSON format: - -```bash -curl "$RIVET_API/gateway/{actor_id}/inspector/traces?startMs=0&endMs=9999999999999&limit=100" -``` - -Returns: - -```json -{ - "otlp": { - "resourceSpans": [ - { - "scopeSpans": [ - { - "spans": [ - { - "traceId": "abc123", - "spanId": "def456", - "name": "increment", - "startTimeUnixNano": "1706000000000000000" - } - ] - } - ] - } - ] - }, - "clamped": false -} -``` - #### Get Workflow History ```bash @@ -654,18 +625,7 @@ Returns: } ``` -When workflow history is present in `/inspector/summary`, `workflowHistory` is returned as the same encoded byte array used by `/inspector/workflow-history`. - -#### Get Metrics (Experimental) - -```bash -curl $RIVET_API/gateway/{actor_id}/inspector/metrics -``` - -Returns in-memory metrics for the current actor wake cycle. Metrics are not persisted and reset when the actor sleeps and wakes again. - -Includes counters for `action_calls`, `action_errors`, `action_duration_ms`, `connections_opened`, `connections_closed`, `sql_statements`, `sql_duration_ms`, and `kv_operations`. - +When workflow history is present in `/inspector/summary`, `workflowHistory` is returned as the same decoded JSON shape as `/inspector/workflow-history`. ### Polling @@ -673,7 +633,9 @@ Inspector endpoints are safe to poll. For live monitoring, poll at 1-5 second in ## OpenAPI Spec -The full OpenAPI specification including all management and actor endpoints is available: +An OpenAPI specification covering many of the management and actor endpoints is available: - In the repository at [`rivetkit-openapi/openapi.json`](https://github.com/rivet-dev/rivet/tree/main/rivetkit-openapi) - Served at `/doc` on the manager when running locally + +The checked-in spec does not yet list every endpoint documented on this page (for example the actor metadata and queue routes and the inspector database routes), so treat this page as the authoritative reference where they differ. diff --git a/website/src/content/docs/actors/design-patterns.mdx b/website/src/content/docs/actors/design-patterns.mdx index 9126394ac6..5daefa96f0 100644 --- a/website/src/content/docs/actors/design-patterns.mdx +++ b/website/src/content/docs/actors/design-patterns.mdx @@ -475,7 +475,7 @@ await session.updateEmail("alice@example.com"); ### Syncing State Changes -Use `onStateChange` to automatically sync actor state changes to external resources. This hook is called whenever the actor's state is modified. +Use `onStateChange` to automatically sync actor state changes to external resources. This hook runs after state changes are flushed, which is coalesced to once per event loop tick rather than once per individual field mutation. Use this when: @@ -576,7 +576,7 @@ const userData = await user.getUser(); -`onStateChange` is called after every state modification, ensuring external resources stay in sync. +`onStateChange` is called once per flush with the final coalesced state, ensuring external resources stay in sync. In the `updateEmail` example above, the two synchronous assignments produce a single `onStateChange` call. Do not mutate `c.state` inside `onStateChange`; re-entrant state mutation is rejected. @@ -618,7 +618,7 @@ const processor = actor({ state: {}, actions: { process: (c, body: unknown) => ({ processed: true }), - destroy: (c) => {}, + destroySelf: (c) => c.destroy(), }, }); @@ -630,7 +630,7 @@ const app = new Hono(); app.post("/process", async (c) => { const actorHandle = client.processor.getOrCreate([crypto.randomUUID()]); const result = await actorHandle.process(await c.req.json()); - await actorHandle.destroy(); + await actorHandle.destroySelf(); return c.json(result); }); ``` diff --git a/website/src/content/docs/actors/destroy.mdx b/website/src/content/docs/actors/destroy.mdx index 51fc845294..b883fece2f 100644 --- a/website/src/content/docs/actors/destroy.mdx +++ b/website/src/content/docs/actors/destroy.mdx @@ -115,7 +115,7 @@ const userActor = actor({ ## Accessing Actor After Destroy -Once an actor is destroyed, any subsequent requests to it will return an `actor_not_found` error. The actor's state is permanently deleted. +Once an actor is destroyed, any subsequent requests to it will fail with an `actor.not_found` error (`{ group: "actor", code: "not_found" }`). The actor's state is permanently deleted. ## API Reference diff --git a/website/src/content/docs/actors/errors.mdx b/website/src/content/docs/actors/errors.mdx index fa3629a7d1..6916a4fc30 100644 --- a/website/src/content/docs/actors/errors.mdx +++ b/website/src/content/docs/actors/errors.mdx @@ -358,7 +358,7 @@ try { } catch (error) { if (error instanceof ActorError) { console.log(error.code); // "internal_error" - console.log(error.message); // "Internal error. Read the server logs for more details." + console.log(error.message); // "An internal error occurred" // Original error details are NOT exposed to the client // Check your server logs to see the actual error message @@ -392,7 +392,7 @@ try { } catch (error) { if (error instanceof ActorError) { console.log(error.code); // "internal_error" - console.log(error.message); // "Internal error. Read the server logs for more details." + console.log(error.message); // "An internal error occurred" // Original error details are NOT exposed to the client // Check your server logs to see the actual error message @@ -420,10 +420,7 @@ The client receives only a generic "Internal error" message for security, but yo **Warning:** Only enable error exposure in development environments. In production, this will leak sensitive internal details to clients. -For faster debugging during development, you can automatically expose internal error details to clients. This is enabled when: - -- `NODE_ENV=development` - Automatically enabled in development mode -- `RIVET_EXPOSE_ERRORS=1` - Explicitly enable error exposure +For faster debugging during development, you can expose internal error details to clients by setting `RIVET_EXPOSE_ERRORS=1`. With error exposure enabled, clients will see the full error message instead of the generic "Internal error" response: @@ -440,14 +437,14 @@ const registry = setup({ use: { payment } }); const client = createClient("http://localhost:6420"); const paymentActor = client.payment.getOrCreate([]); -// With NODE_ENV=development or RIVET_EXPOSE_ERRORS=1 +// With RIVET_EXPOSE_ERRORS=1 try { await paymentActor.processPayment(100); } catch (error) { if (error instanceof ActorError) { console.log(error.message); // "Payment API returned 402: Insufficient funds" - // Instead of: "Internal error. Read the server logs for more details." + // Instead of: "An internal error occurred" } } ``` diff --git a/website/src/content/docs/actors/events.mdx b/website/src/content/docs/actors/events.mdx index 9e83ae8cfe..0088e80b08 100644 --- a/website/src/content/docs/actors/events.mdx +++ b/website/src/content/docs/actors/events.mdx @@ -78,9 +78,7 @@ const gameRoom = actor({ }>() }, - connState: { playerId: "", role: "player" } as ConnState, - - createConnState: (c, params: { playerId: string, role?: string }) => ({ + createConnState: (c, params: { playerId: string, role?: string }): ConnState => ({ playerId: params.playerId, role: params.role || "player" }), @@ -132,9 +130,7 @@ const gameRoom = actor({ }>() }, - connState: { playerId: "", role: "player" } as ConnState, - - createConnState: (c, params: { playerId: string, role?: string }) => ({ + createConnState: (c, params: { playerId: string, role?: string }): ConnState => ({ playerId: params.playerId, role: params.role || "player" }), diff --git a/website/src/content/docs/actors/index.mdx b/website/src/content/docs/actors/index.mdx index 60dcff17cc..b39d539e17 100644 --- a/website/src/content/docs/actors/index.mdx +++ b/website/src/content/docs/actors/index.mdx @@ -113,7 +113,6 @@ interface CounterState { } const counter = actor({ - state: { count: 0 } as CounterState, createState: (c, input: { start?: number }): CounterState => ({ count: input.start ?? 0, }), @@ -273,7 +272,7 @@ const chatRoom = actor({ ### Connections -Access the current connection via `c.conn` or all connected clients via `c.conns`. Use `c.conn.id` or `c.conn.state` to securely identify who is calling an action. Connection state is initialized via `connState` or `createConnState`, which receives parameters passed by the client on connect. +Access the current connection via `c.conn` or all connected clients via `c.conns`. Use `c.conn.id` or `c.conn.state` to securely identify who is calling an action. `c.conn` is only available for actions invoked through a connected client; stateless actor-handle calls run without a connection, so guard against that. Connection state is initialized via `connState` or `createConnState`, which receives parameters passed by the client on connect. @@ -486,9 +485,6 @@ interface ConnState { } const chatRoom = actor({ - state: { users: {} } as RoomState, - vars: { startTime: 0 }, - connState: { userId: "", joinedAt: 0 } as ConnState, events: { stateChanged: event(), }, @@ -602,12 +598,22 @@ c.state.username = username; ```ts -import { actor, setup } from "rivetkit"; +import { actor, setup, UserError } from "rivetkit"; import { createClient, ActorError } from "rivetkit/client"; const user = actor({ state: { username: "" }, - actions: { updateUsername: (c, username: string) => { c.state.username = username; } } + actions: { + updateUsername: (c, username: string) => { + if (username.length < 3) { + throw new UserError("Username too short", { + code: "username_too_short", + metadata: { minLength: 3, actual: username.length }, + }); + } + c.state.username = username; + }, + }, }); const registry = setup({ use: { user } }); diff --git a/website/src/content/docs/actors/input.mdx b/website/src/content/docs/actors/input.mdx index 2f533b61df..5f31abc0ee 100644 --- a/website/src/content/docs/actors/input.mdx +++ b/website/src/content/docs/actors/input.mdx @@ -21,7 +21,6 @@ interface GameInput { } const game = actor({ - state: { gameMode: "", maxPlayers: 0, difficulty: "medium" }, createState: (c, input: GameInput) => ({ gameMode: input.gameMode, maxPlayers: input.maxPlayers, @@ -53,7 +52,7 @@ const gameHandle2 = client.game.getOrCreate(["game-456"], { ## Accessing Input in Lifecycle Hooks -Input is available in lifecycle hooks via the `opts.input` parameter: +Input is available as the second argument to the `createState` and `onCreate` lifecycle hooks: ```typescript import { actor } from "rivetkit"; @@ -78,7 +77,6 @@ function setupPrivateRoomLogging(roomName: string) { } const chatRoom = actor({ - state: { name: "", isPrivate: false, maxUsers: 50, users: {}, messages: [] } as ChatRoomState, createState: (c, input: ChatRoomInput): ChatRoomState => ({ name: input?.roomName ?? "Unnamed Room", isPrivate: input?.isPrivate ?? false, @@ -133,7 +131,6 @@ interface GameState { } const game = actor({ - state: { gameMode: "", maxPlayers: 0, difficulty: "medium", players: {}, gameState: "waiting" } as GameState, createState: (c, inputRaw: GameInput): GameState => { // Validate input const input = GameInputSchema.parse(inputRaw); @@ -179,9 +176,7 @@ import { createClient } from "rivetkit/client"; interface RoomInput { roomName: string; isPrivate: boolean; } const chatRoom = actor({ - state: { name: "", isPrivate: false }, createState: (c, input: RoomInput) => ({ name: input.roomName, isPrivate: input.isPrivate }), - connState: { userId: "", displayName: "" }, createConnState: (c, params: { userId: string; displayName: string }) => ({ userId: params.userId, displayName: params.displayName, @@ -223,7 +218,6 @@ interface GameState { } const game = actor({ - state: { gameMode: "", maxPlayers: 0, difficulty: "medium" } as GameState, createState: (c, input: GameInput): GameState => ({ gameMode: input.gameMode, maxPlayers: input.maxPlayers, @@ -262,11 +256,6 @@ interface GameState { } const game = actor({ - state: { - config: { gameMode: "", maxPlayers: 0, difficulty: "medium" }, - players: {}, - gameState: "waiting" - } as GameState, createState: (c, input: GameInput): GameState => ({ // Store input configuration in state config: { @@ -292,4 +281,4 @@ const game = actor({ - [`CreateOptions`](/typedoc/interfaces/rivetkit.client_mod.CreateOptions.html) - Options for creating actors - [`CreateRequest`](/typedoc/types/rivetkit.client_mod.CreateRequest.html) - Request type for creation -- [`ActorDefinition`](/typedoc/interfaces/rivetkit.mod.ActorDefinition.html) - Interface for defining input types +- [`ActorDefinition`](/typedoc/classes/rivetkit.mod.ActorDefinition.html) - Actor definition returned by `actor()` diff --git a/website/src/content/docs/actors/inspector-tabs.mdx b/website/src/content/docs/actors/inspector-tabs.mdx index 1c31a56533..59f0fbb853 100644 --- a/website/src/content/docs/actors/inspector-tabs.mdx +++ b/website/src/content/docs/actors/inspector-tabs.mdx @@ -165,12 +165,10 @@ arrives. } ``` -For tabs with sub-views, the dashboard also sends `set-active-tab` -when the user switches: - -```ts @nocheck -{ type: "set-active-tab", v: 1, tab: string } -``` +Multi-view tabs can read the optional `activeTab` field on `init` to +seed their initial sub-view. The dashboard does not send a separate +message when the user switches custom tabs — it navigates the iframe +`src` instead, so the tab reloads and receives a fresh `init`. ### From the tab diff --git a/website/src/content/docs/actors/keys.mdx b/website/src/content/docs/actors/keys.mdx index 481e226f5e..31f998946c 100644 --- a/website/src/content/docs/actors/keys.mdx +++ b/website/src/content/docs/actors/keys.mdx @@ -182,7 +182,6 @@ interface UserSessionState { } const userSession = actor({ - state: { userId: "", loginTime: 0, preferences: {} } as UserSessionState, createState: (c): UserSessionState => ({ userId: c.key[0], // Extract user ID from key loginTime: Date.now(), @@ -234,7 +233,6 @@ interface ChatRoomInput { } const chatRoom = actor({ - state: { maxUsers: 0, isPrivate: false, moderators: [] as string[], settings: { allowImages: true, slowMode: false } }, createState: (c, input: ChatRoomInput) => ({ maxUsers: input.maxUsers, isPrivate: input.isPrivate, diff --git a/website/src/content/docs/actors/kv.mdx b/website/src/content/docs/actors/kv.mdx index c342f05afa..ae4e555e1f 100644 --- a/website/src/content/docs/actors/kv.mdx +++ b/website/src/content/docs/actors/kv.mdx @@ -34,7 +34,7 @@ const greetings = actor({ ## Value Types -You can store binary values by passing `Uint8Array` or `ArrayBuffer` directly. Use `type` when reading to get the right return type. +You can store binary values by passing `Uint8Array` or `ArrayBuffer`. Use `type` on both reads and writes to get the right value type: `binary` for `Uint8Array` and `arrayBuffer` for `ArrayBuffer`. ```typescript import { actor } from "rivetkit"; @@ -49,7 +49,10 @@ const assets = actor({ return await c.kv.get("avatar", { type: "binary" }); }, putSnapshot: async (c, data: ArrayBuffer) => { - await c.kv.put("snapshot", data); + await c.kv.put("snapshot", data, { type: "arrayBuffer" }); + }, + getSnapshot: async (c) => { + return await c.kv.get("snapshot", { type: "arrayBuffer" }); }, }, }); @@ -110,15 +113,11 @@ const example = actor({ state: {}, actions: { pruneAndScan: async (c) => { - const encoder = new TextEncoder(); - const active = await c.kv.listRange( - encoder.encode("job:"), - encoder.encode("joc:"), - { - keyType: "text", - }, - ); + const active = await c.kv.listRange("job:", "joc:", { + keyType: "text", + }); + const encoder = new TextEncoder(); await c.kv.deleteRange( encoder.encode("job:old:"), encoder.encode("job:old;"), @@ -132,7 +131,7 @@ const example = actor({ ## Batch Operations -KV supports batch operations for efficiency. Defaults are still `text` for both keys and values. +KV supports batch operations for efficiency. `batchPut` and `batchGet` work on raw `Uint8Array` keys and values, so encode strings before passing them in. ```typescript import { actor } from "rivetkit"; @@ -141,12 +140,17 @@ const example = actor({ state: {}, actions: { batchOps: async (c) => { - await c.kv.putBatch([ - ["alpha", "1"], - ["beta", "2"], + const encoder = new TextEncoder(); + + await c.kv.batchPut([ + [encoder.encode("alpha"), encoder.encode("1")], + [encoder.encode("beta"), encoder.encode("2")], ]); - const values = await c.kv.getBatch(["alpha", "beta"]); + const values = await c.kv.batchGet([ + encoder.encode("alpha"), + encoder.encode("beta"), + ]); }, }, }); diff --git a/website/src/content/docs/actors/lifecycle.mdx b/website/src/content/docs/actors/lifecycle.mdx index 88ae401359..fa959b8fbf 100644 --- a/website/src/content/docs/actors/lifecycle.mdx +++ b/website/src/content/docs/actors/lifecycle.mdx @@ -29,11 +29,12 @@ Loading ──Start──▶ Ready ──spawn driver──▶ Started **On Create** (runs once per actor) -1. `createState` -2. `onCreate` -3. `createVars` -4. `onWake` -5. `run` (background, does not block) +1. `onMigrate` +2. `createState` +3. `onCreate` +4. `createVars` +5. `onWake` +6. `run` (background, does not block) **On Destroy** @@ -41,9 +42,10 @@ Loading ──Start──▶ Ready ──spawn driver──▶ Started **On Wake** (after sleep, restart, or crash) -1. `createVars` -2. `onWake` -3. `run` (background, does not block) +1. `onMigrate` +2. `createVars` +3. `onWake` +4. `run` (background, does not block) **On Sleep** (after idle period) @@ -91,6 +93,26 @@ const counter = actor({ }); ``` +### `onMigrate` + +[API Reference](/typedoc/interfaces/rivetkit.mod.ActorDefinition.html) + +The `onMigrate` hook runs on every actor start, before `createState`, `onCreate`, `createVars`, and `onWake`. Can be async. It runs early so that database migrations are applied before any other lifecycle hook accesses the database. The second parameter is `true` when the actor is being created for the first time. + +```typescript +import { actor } from "rivetkit"; + +const counter = actor({ + state: { count: 0 }, + + onMigrate: (c, isNew) => { + // Run database migrations before any other lifecycle hook + }, + + actions: { /* ... */ } +}); +``` + ### `createState` [API Reference](/typedoc/interfaces/rivetkit.mod.ActorDefinition.html) @@ -126,7 +148,7 @@ const counter = actor({ [API Reference](/typedoc/interfaces/rivetkit.mod.ActorDefinition.html) -The `createVars` function dynamically initializes ephemeral variables. Can be async. Use this when you need to initialize values at runtime. The `driverCtx` parameter provides driver-specific context. See [ephemeral variables documentation](/docs/actors/state#ephemeral-variables) for more information. +The `createVars` function dynamically initializes ephemeral variables. Can be async. Use this when you need to initialize values at runtime. See [ephemeral variables documentation](/docs/actors/state#ephemeral-variables) for more information. ```typescript import { actor } from "rivetkit"; @@ -138,7 +160,7 @@ interface CounterVars { const counter = actor({ state: { count: 0 }, - createVars: (c, driverCtx): CounterVars => ({ + createVars: (c): CounterVars => ({ lastAccessTime: Date.now(), emitter: new EventTarget() }), @@ -913,8 +935,8 @@ const myActor = actor({ // Total graceful shutdown budget for both sleep and destroy. Default: 15000ms. sleepGracePeriod: 15_000, - // Interval for saving state (default: 10000ms) - stateSaveInterval: 10_000, + // Interval for saving state (default: 1000ms) + stateSaveInterval: 1_000, // Timeout for action execution (default: 60000ms) actionTimeout: 60_000, @@ -943,7 +965,7 @@ const myActor = actor({ | `createConnStateTimeout` | 5000ms | Timeout for `createConnState` function | | `onConnectTimeout` | 5000ms | Timeout for `onConnect` hook | | `sleepGracePeriod` | 15000ms | Total graceful shutdown window for both sleep and destroy | -| `stateSaveInterval` | 10000ms | Interval for persisting state | +| `stateSaveInterval` | 1000ms | Interval for persisting state | | `actionTimeout` | 60000ms | Timeout for action execution | | `connectionLivenessTimeout` | 2500ms | Timeout for connection liveness check | | `connectionLivenessInterval` | 5000ms | Interval for connection liveness check | diff --git a/website/src/content/docs/actors/limits.mdx b/website/src/content/docs/actors/limits.mdx index 22b1750181..f8b6530211 100644 --- a/website/src/content/docs/actors/limits.mdx +++ b/website/src/content/docs/actors/limits.mdx @@ -11,7 +11,7 @@ There are two types of limits: - **Soft Limit**: Application-level limit, configurable in RivetKit. These cannot exceed the hard limit. - **Hard Limit**: Infrastructure-level limit that cannot be configured. -Soft limits can be configured in RivetKit by passing options to `setup`: +Soft limits are configured in two places. Registry-level WebSocket message-size limits are passed to `setup`: ```typescript import { setup } from "rivetkit"; @@ -20,6 +20,20 @@ const rivet = setup({ use: { /* ... */ }, maxIncomingMessageSize: 1_048_576, maxOutgoingMessageSize: 10_485_760, +}); +``` + +Per-actor limits such as queue sizes and lifecycle timeouts are passed to `actor(...)` via `options`: + +```typescript +import { actor } from "rivetkit"; + +const myActor = actor({ + options: { + maxQueueSize: 1000, + actionTimeout: 60_000, + stateSaveInterval: 1_000, + }, // ... }); ``` @@ -133,7 +147,7 @@ See [Actor Input](/docs/actors/input) for details. | On connect timeout | 5 seconds | — | Timeout for `onConnect` hook. Configurable via `onConnectTimeout`. | | Sleep grace period | 15 seconds | — | Total graceful shutdown budget for both sleep and destroy. Covers `onSleep`/`onDestroy`, run handler shutdown, `waitUntil`, `keepAwake`, async raw WebSocket handlers, and connection cleanup. Configurable via `sleepGracePeriod`. | | Sleep timeout | 30 seconds | — | Time of inactivity before actor hibernates. Configurable via `sleepTimeout`. | -| State save interval | 10 seconds | — | Interval between automatic state saves. Configurable via `stateSaveInterval`. | +| State save interval | 1 second | — | Interval between automatic state saves. Configurable via `stateSaveInterval`. | ### Serverless Shutdown diff --git a/website/src/content/docs/actors/metadata.mdx b/website/src/content/docs/actors/metadata.mdx index 6a86c80f19..508e8375a9 100644 --- a/website/src/content/docs/actors/metadata.mdx +++ b/website/src/content/docs/actors/metadata.mdx @@ -135,4 +135,4 @@ console.log("Actor metadata:", metadata); ## API Reference - [`ActorDefinition`](/typedoc/interfaces/rivetkit.mod.ActorDefinition.html) - Interface for defining metadata -- [`CreateOptions`](/typedoc/interfaces/rivetkit.client_mod.CreateOptions.html) - Includes metadata options +- [`CreateOptions`](/typedoc/interfaces/rivetkit.client_mod.CreateOptions.html) - Options for creating an actor, including `region` and `input` diff --git a/website/src/content/docs/actors/queues.mdx b/website/src/content/docs/actors/queues.mdx index d57437992c..97271025ba 100644 --- a/website/src/content/docs/actors/queues.mdx +++ b/website/src/content/docs/actors/queues.mdx @@ -61,8 +61,8 @@ await handle.send("increment", { amount: 5 }); Use this when you want explicit completion/ack semantics but do not need to return data. -- If processing fails before `message.complete()`, the message is not acknowledged. -- Unacknowledged messages are retried, so mutation handlers should be idempotent. +- `message.complete()` resolves a sender waiting on `wait: true` (or `enqueueAndWait`). It does not change durability: messages are removed from queue storage when they are received, not when they are completed. +- If processing fails before `message.complete()`, the message is not redelivered, and any waiting sender times out instead of receiving a completion. - `status: "timedOut"` means sender timeout elapsed before `message.complete(...)`. diff --git a/website/src/content/docs/actors/quickstart/backend.mdx b/website/src/content/docs/actors/quickstart/backend.mdx index b247c57664..ea7b3559f3 100644 --- a/website/src/content/docs/actors/quickstart/backend.mdx +++ b/website/src/content/docs/actors/quickstart/backend.mdx @@ -28,6 +28,12 @@ npx skills add rivet-dev/skills npm install rivetkit ``` +If you plan to connect from a React frontend, also install `@rivetkit/react`: + +```sh +npm install @rivetkit/react +``` + diff --git a/website/src/content/docs/actors/quickstart/effect.mdx b/website/src/content/docs/actors/quickstart/effect.mdx new file mode 100644 index 0000000000..c62310dea5 --- /dev/null +++ b/website/src/content/docs/actors/quickstart/effect.mdx @@ -0,0 +1,212 @@ +--- +title: "Effect.ts Quickstart (Beta)" +description: "Build a Rivet Actor with the Effect SDK" +skill: true +--- + +import { Hosting } from "@/components/docs/Hosting"; + + +Effect support is in beta. The `@rivetkit/effect` API may change between releases. See the [`hello-world-effect`](https://github.com/rivet-dev/rivet/tree/main/examples/hello-world-effect) and [`chat-room-effect`](https://github.com/rivet-dev/rivet/tree/main/examples/chat-room-effect) examples for complete runnable projects. + + +## Steps + + + + +Add `rivetkit`, the Effect SDK, and its Effect peers: + +```sh +npm install rivetkit @rivetkit/effect effect @effect/platform-node +``` + + + + + +Split each actor into a public contract and a server-only implementation so the contract can be imported from client code without leaking server details. + +The contract declares the actor and its actions. Actions are standalone values with explicit [`effect/Schema`](https://effect.website/docs/schema/introduction/) payloads and successes, validated end to end: + +```ts src/actors/counter/api.ts @nocheck +import { Action, Actor } from "@rivetkit/effect"; +import { Schema } from "effect"; + +export const Increment = Action.make("Increment", { + payload: { amount: Schema.Number }, + success: Schema.Number, +}); + +export const GetCount = Action.make("GetCount", { + success: Schema.Number, +}); + +export const Counter = Actor.make("Counter", { + actions: [Increment, GetCount], +}); +``` + +The implementation registers the actor with `.toLayer`. The wake function runs once when the actor awakes and returns the action handlers. Persisted state is accessed through a `SubscriptionRef`-like `State` API: + +```ts src/actors/counter/live.ts @nocheck +import { Actor, State } from "@rivetkit/effect"; +import { Effect, Schema } from "effect"; +import { Counter } from "./api.ts"; + +export const CounterLive = Counter.toLayer( + Effect.fnUntraced(function* ({ rawRivetkitContext, state }) { + return Counter.of({ + Increment: Effect.fnUntraced(function* ({ payload }) { + const next = yield* State.updateAndGet(state, (current) => ({ + count: current.count + payload.amount, + })).pipe(Effect.orDie); + + // Broadcast the new value to every connected client. + rawRivetkitContext.broadcast("newCount", next.count); + + return next.count; + }), + GetCount: () => + State.get(state).pipe( + Effect.map((current) => current.count), + Effect.orDie, + ), + }); + }), + { + state: { + schema: Schema.Struct({ count: Schema.Number }), + initialValue: () => ({ count: 0 }), + }, + name: "Counter", + icon: "calculator", + }, +); +``` + + + + + +Compose the actor layers and serve them with `Registry.serve`. `Registry.layer()` reads engine config from the environment, and the actor layer is provided a `Client` so actors can call other actors: + +```ts src/main.ts @nocheck +import { NodeRuntime } from "@effect/platform-node"; +import { Client, Registry } from "@rivetkit/effect"; +import { Layer } from "effect"; +import { CounterLive } from "./actors/counter/live.ts"; + +const endpoint = process.env.RIVET_ENDPOINT ?? "http://127.0.0.1:6420"; + +const ActorsLayer = CounterLive.pipe(Layer.provide(Client.layer({ endpoint }))); + +const MainLayer = Registry.serve(ActorsLayer).pipe(Layer.provide(Registry.layer())); + +// Keeps the layer alive. Tears down on SIGINT/SIGTERM. +Layer.launch(MainLayer).pipe(NodeRuntime.runMain); +``` + + + + + +Set `RIVET_RUN_ENGINE=1` to spawn a local Rivet Engine alongside the server. The engine binary is downloaded and cached the first time you run, so there is nothing else to install: + +```sh +RIVET_RUN_ENGINE=1 npx tsx --watch src/main.ts +``` + +Your server now connects to the Rivet Engine on `http://localhost:6420`. Clients connect directly to the engine on this port. + + +To point at a remote engine instead, set `RIVET_ENDPOINT=https://...` and omit `RIVET_RUN_ENGINE`. + + + + + + +This code can run either in your frontend or within your backend: + + + + + +The Effect client imports the same actor contract from your registry. `Counter.client` yields a typed accessor backed by the client layer: + +```ts src/client.ts @nocheck +import { NodeRuntime } from "@effect/platform-node"; +import { Client } from "@rivetkit/effect"; +import { Effect } from "effect"; +import { Counter } from "./actors/counter/api.ts"; + +const program = Effect.gen(function* () { + const counter = (yield* Counter.client).getOrCreate("my-counter"); + + const count = yield* counter.Increment({ amount: 3 }); + yield* Effect.log(`New count: ${count}`); + + const total = yield* counter.GetCount(); + yield* Effect.log(`Total: ${total}`); +}); + +const ClientLayer = Client.layer({ endpoint: "http://localhost:6420" }); + +program.pipe(Effect.provide(ClientLayer), NodeRuntime.runMain); +``` + +With the server still running, start the client in another terminal: + +```sh +npx tsx src/client.ts +``` + +See the [`chat-room-effect`](https://github.com/rivet-dev/rivet/tree/main/examples/chat-room-effect) example for a larger project with typed errors and actor-to-actor calls. + + + + + +A plain RivetKit client can call your Effect actor by name through the same engine. Actor and action names are resolved at runtime, so the client is untyped here: + +```ts client.ts @nocheck +import { createClient } from "rivetkit/client"; + +const client = createClient("http://localhost:6420"); + +const counter = client.Counter.getOrCreate(["my-counter"]); + +const count = await counter.Increment({ amount: 3 }); +console.log("New count:", count); +``` + +See the [JavaScript client documentation](/docs/clients/javascript) for more information. + + + + + + + + + + + + + + + +## Next Steps + + + + Define the RPC surface clients call on your actor. + + + Persist and load actor state across sleeps and restarts. + + + Broadcast realtime updates to connected clients. + + diff --git a/website/src/content/docs/actors/quickstart/index.mdx b/website/src/content/docs/actors/quickstart/index.mdx index b82a1c8417..0421a0d0d0 100644 --- a/website/src/content/docs/actors/quickstart/index.mdx +++ b/website/src/content/docs/actors/quickstart/index.mdx @@ -7,6 +7,7 @@ skill: false import { faCloudflare, faFunction, + faLayerGroup, faNodeJs, faReact, faNextjs, @@ -43,12 +44,19 @@ npx skills add rivet-dev/skills Build server-rendered Next.js experiences backed by actors Build a Rivet Actor in Rust with the typed `rivetkit` crate + + Build a Rivet Actor with the Effect SDK and `effect/Schema` + @@ -87,9 +87,12 @@ import { createRivetKit } from "@rivetkit/next-js/client"; import type { registry } from "@/rivet/registry"; import { useState } from "react"; -export const { useActor } = createRivetKit( - process.env.NEXT_RIVET_ENDPOINT ?? "http://localhost:3000/api/rivet", -); +export const { useActor } = createRivetKit({ + endpoint: + process.env.NEXT_PUBLIC_RIVET_ENDPOINT ?? "http://localhost:3000/api/rivet", + namespace: process.env.NEXT_PUBLIC_RIVET_NAMESPACE, + token: process.env.NEXT_PUBLIC_RIVET_TOKEN, +}); export function Counter() { const [count, setCount] = useState(0); diff --git a/website/src/content/docs/actors/quickstart/rust.mdx b/website/src/content/docs/actors/quickstart/rust.mdx index e411f7ef60..0ba6715435 100644 --- a/website/src/content/docs/actors/quickstart/rust.mdx +++ b/website/src/content/docs/actors/quickstart/rust.mdx @@ -1,5 +1,5 @@ --- -title: "Rust Quickstart (Preview)" +title: "Rust Quickstart (Beta)" description: "Build a Rivet Actor in Rust" skill: true --- @@ -7,7 +7,7 @@ skill: true import { Hosting } from "@/components/docs/Hosting"; -Rust support is in preview. The supported public Rust API is `rivetkit` and `rivetkit-client`; lower-level crates are internal implementation details and do not carry a stability guarantee. +Rust support is in beta. The supported public Rust API is `rivetkit` and `rivetkit-client`; lower-level crates are internal implementation details and do not carry a stability guarantee. See the full API reference on [docs.rs/rivetkit](https://docs.rs/rivetkit). ## Steps @@ -15,7 +15,7 @@ Rust support is in preview. The supported public Rust API is `rivetkit` and `riv -Add the `rivetkit` crate: +Add the `rivetkit` crate and its companions: ```sh cargo add rivetkit@2.3.0-rc.12 anyhow async-trait @@ -25,11 +25,11 @@ cargo add tokio --features full - + -An actor is a type that implements `Actor`, plus one `Handles` implementation for each action. Persisted state lives in `type State`; ephemeral runtime state is just fields on your actor struct. +Put the actor in `src/lib.rs` so both your server and your client can share the same types. An actor is a type that implements `Actor`, plus one `Handles` implementation for each action. Persisted state lives in `type State`; ephemeral runtime state is just fields on your actor struct. -```rust src/main.rs +```rust src/lib.rs use std::{future::Future, pin::Pin, sync::Arc}; use async_trait::async_trait; @@ -38,16 +38,16 @@ use serde::{Deserialize, Serialize}; type BoxFuture = Pin> + Send>>; -struct Counter; +pub struct Counter; #[derive(Default, Serialize, Deserialize)] -struct CounterState { - count: i64, +pub struct CounterState { + pub count: i64, } #[derive(Serialize, Deserialize)] -struct Increment { - amount: i64, +pub struct Increment { + pub amount: i64, } impl Action for Increment { @@ -57,8 +57,8 @@ impl Action for Increment { } #[derive(Serialize, Deserialize)] -struct NewCount { - count: i64, +pub struct NewCount { + pub count: i64, } impl Event for NewCount { @@ -101,27 +101,44 @@ impl Handles for Counter { } } -#[tokio::main] -async fn main() -> Result<()> { +pub fn registry() -> Registry { let mut registry = Registry::new(); registry.register_actor::("counter"); - registry.start().await + registry +} +``` + + + + + +Your `src/main.rs` just starts the registry from the library: + +```rust src/main.rs +#[tokio::main] +async fn main() -> anyhow::Result<()> { + counter::registry().start().await } ``` +Replace `counter` with your crate name (the package `name` in `Cargo.toml`, with dashes as underscores). + -The Rust runtime connects to the Rivet Engine. Build the engine binary once, then start your server. `RIVET_ENGINE_BINARY_PATH` tells the runtime where to find the engine; it spawns or reuses a local engine at `http://localhost:6420`. +The Rust runtime connects to the Rivet Engine. Setting `RIVETKIT_ENGINE_AUTO_DOWNLOAD=1` lets the runtime download and cache a matching engine binary the first time you run, so there is nothing else to install: ```sh -cargo build -p rivet-engine -RIVET_ENGINE_BINARY_PATH=./target/debug/rivet-engine cargo run +RIVETKIT_ENGINE_AUTO_DOWNLOAD=1 cargo run ``` Your server now connects to the Rivet Engine on `http://localhost:6420`. Clients connect directly to the engine on this port. + +Already have an engine binary? Set `RIVET_ENGINE_BINARY_PATH=/path/to/rivet-engine` to point at it instead. If you are working inside the [Rivet monorepo](https://github.com/rivet-dev/rivet), a local `cargo build -p rivet-engine` is discovered automatically from `target/debug`. + + @@ -132,55 +149,15 @@ This code can run either in your frontend or within your backend: -```rust src/client.rs -use anyhow::Result; +Add a `src/bin/client.rs` that imports the same actor types from your library. There is no need to redefine the actor on the client. + +```rust src/bin/client.rs +use counter::{Counter, Increment, NewCount}; use rivetkit::{ - client::{Client, ClientConfig, GetOrCreateOptions}, + client::{Client, ClientConfig}, prelude::*, TypedClientExt, }; -use serde::{Deserialize, Serialize}; - -struct Counter; - -#[derive(Serialize, Deserialize)] -struct Increment { - amount: i64, -} - -impl Action for Increment { - type Output = i64; - - const NAME: &'static str = "increment"; -} - -#[derive(Serialize, Deserialize)] -struct NewCount { - count: i64, -} - -impl Event for NewCount { - const NAME: &'static str = "newCount"; -} - -impl Actor for Counter { - type State = (); - type Input = (); - type Actions = (Increment,); - type Events = (NewCount,); - type Queue = (); - type ConnParams = (); - type ConnState = (); - type Action = action::Raw; -} - -impl Handles for Counter { - type Future = std::future::Ready>; - - fn handle(self: std::sync::Arc, _ctx: Ctx, _action: Increment) -> Self::Future { - unreachable!("client-only type marker") - } -} #[tokio::main] async fn main() -> Result<()> { @@ -200,6 +177,12 @@ async fn main() -> Result<()> { } ``` +With the server still running, start the client in another terminal: + +```sh +cargo run --bin client +``` + See the [`hello-world-rust`](https://github.com/rivet-dev/rivet/tree/main/examples/hello-world-rust) example for a complete runnable counter. @@ -215,15 +198,15 @@ const client = createClient("http://localhost:6420"); const counter = client.counter.getOrCreate(["my-counter"]); -const count = await counter.increment(3); -console.log("New count:", count); - -const connection = counter.connect(); -connection.on("newCount", (newCount: { count: number }) => { - console.log("Count changed:", newCount.count); +const counterConnection = counter.connect(); +counterConnection.on("newCount", (event) => { + console.log("Event count:", event.count); }); -await connection.increment(1); +const count = await counterConnection.increment(3); +console.log("New count:", count); + +await counterConnection.increment(1); ``` See the [JavaScript client documentation](/docs/clients/javascript) for more information. @@ -246,12 +229,14 @@ function Counter() { key: ["my-counter"], }); - counter.useEvent("newCount", (event: { count: number }) => setCount(event.count)); - const increment = async () => { await counter.connection?.increment(1); }; + counter.useEvent("newCount", (event) => { + setCount(event.count); + }); + return (

Count: {count}

@@ -276,3 +261,20 @@ See the [React documentation](/docs/clients/react) for more information. + +## Next Steps + + + + Full `rivetkit` crate documentation on docs.rs. + + + Define the RPC surface clients call on your actor. + + + Persist and load actor state across sleeps and restarts. + + + Broadcast realtime updates to connected clients. + + diff --git a/website/src/content/docs/actors/request-handler.mdx b/website/src/content/docs/actors/request-handler.mdx index 04a6336eb0..b04112d4fe 100644 --- a/website/src/content/docs/actors/request-handler.mdx +++ b/website/src/content/docs/actors/request-handler.mdx @@ -185,7 +185,7 @@ const response = await fetch( { method: "POST", headers: { - Authorization: `Bearer ${token}`, + "x-rivet-token": token, }, } ); @@ -195,7 +195,7 @@ console.log(data); // { count: 1 } ```bash curl -X POST "https://api.rivet.dev/gateway/{actorId}/request/increment" \ - -H "Authorization: Bearer {token}" + -H "x-rivet-token: {token}" ``` @@ -226,9 +226,14 @@ app.all("/actors/:id/:path{.*}", async (c) => { const actorId = c.req.param("id"); const actorPath = (c.req.param("path") || ""); - // Forward to actor's onRequest handler + // Rewrite the incoming request to the actor-relative path, preserving + // method, headers, and body + const url = new URL(actorPath, "http://actor"); + const actorRequest = new Request(url, c.req.raw); + + // Forward the rewritten Request to the actor's onRequest handler const actor = client.counter.get(actorId); - return await actor.fetch(actorPath, c.req.raw); + return await actor.fetch(actorRequest); }); serve(app); diff --git a/website/src/content/docs/actors/sandbox.mdx b/website/src/content/docs/actors/sandbox.mdx deleted file mode 100644 index 20cdb50e7f..0000000000 --- a/website/src/content/docs/actors/sandbox.mdx +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: "Sandbox Actor" -description: "The legacy TypeScript sandbox actor has been removed while the replacement runtime is rebuilt." -skill: true ---- - -The legacy TypeScript sandbox actor and provider exports were removed from -`rivetkit` while the replacement runtime is rebuilt. - -## Current status - -- The `rivetkit/sandbox` package path does not exist on this branch. -- The old `sandbox-agent` wrapper was intentionally deleted. -- The old code examples were removed so the docs stop advertising broken imports. - -## What to use instead - -- For actor hosting, use `Registry.startEnvoy()` and the native `rivetkit-core` - path. -- If you still need sandbox orchestration immediately, integrate - `sandbox-agent` directly in your own application code instead of relying on a - removed `rivetkit` wrapper. - -This page will be replaced when the new runtime lands. diff --git a/website/src/content/docs/actors/schedule.mdx b/website/src/content/docs/actors/schedule.mdx index b96fd17989..7938f4b166 100644 --- a/website/src/content/docs/actors/schedule.mdx +++ b/website/src/content/docs/actors/schedule.mdx @@ -58,7 +58,7 @@ const reminderService = actor({ state: { reminders: {} } as ReminderState, actions: { - setReminder: (c, userId: string, message: string, delayMs: number) => { + setReminder: async (c, userId: string, message: string, delayMs: number) => { const reminderId = crypto.randomUUID(); // Store the reminder in state @@ -69,7 +69,7 @@ const reminderService = actor({ }; // Schedule the sendReminder action to run after the delay - c.schedule.after(delayMs, "sendReminder", reminderId); + await c.schedule.after(delayMs, "sendReminder", reminderId); return { reminderId }; }, diff --git a/website/src/content/docs/actors/sqlite-drizzle.mdx b/website/src/content/docs/actors/sqlite-drizzle.mdx index 199150a28b..26bed50195 100644 --- a/website/src/content/docs/actors/sqlite-drizzle.mdx +++ b/website/src/content/docs/actors/sqlite-drizzle.mdx @@ -35,8 +35,9 @@ src/ ``` - `index.ts` is the actor implementation. -- `drizzle/` contains files managed by `drizzle-kit`. -- Commit generated migration files to source control. +- `drizzle/` holds the SQL migrations (`*.sql`) and `meta/_journal.json` generated by `drizzle-kit`. +- `migrations.js` is a small RivetKit glue file you maintain by hand. It imports the journal and each `*.sql` file and exports a `{ journal, migrations }` object keyed by migration (for example `m0000`). Add a new entry here whenever `db:generate` produces a new migration. +- Commit the generated migration files and `migrations.js` to source control. ## Basic setup @@ -216,9 +217,13 @@ await c.db.execute( ## Queues -Use queues for ordered mutations and keep actions read-only. +Use queues for ordered mutations and keep actions read-only. Import `queue` alongside `actor` from `rivetkit`. ```ts @nocheck +import { actor, queue } from "rivetkit"; + +// ... + queues: { addTodo: queue<{ title: string }>(), }, diff --git a/website/src/content/docs/actors/sqlite.mdx b/website/src/content/docs/actors/sqlite.mdx index bb10c65623..eba392ab1b 100644 --- a/website/src/content/docs/actors/sqlite.mdx +++ b/website/src/content/docs/actors/sqlite.mdx @@ -251,7 +251,6 @@ console.log(todos); - `GET /inspector/database/schema` returns the tables and views discovered in the actor's SQLite database. - `GET /inspector/database/rows?table=...&limit=100&offset=0` returns paged rows for a specific table or view. - `POST /inspector/database/execute` lets you run ad-hoc SQL for debugging and data fixes with positional `args` or named `properties`. -- `GET /inspector/traces` helps inspect slow query paths and SQL-heavy actions. - Keep a small read-only action for quick query verification while debugging. - In non-dev mode, inspector endpoints require authorization. diff --git a/website/src/content/docs/actors/state.mdx b/website/src/content/docs/actors/state.mdx index 72a22beb00..dcdd808127 100644 --- a/website/src/content/docs/actors/state.mdx +++ b/website/src/content/docs/actors/state.mdx @@ -1,252 +1,130 @@ --- -title: "State & Storage" -description: "Choose where to store data in your actors: in-memory state for small serializable values, embedded SQLite for large or queryable data, and ephemeral variables for connections to external databases and non-serializable runtime objects." +title: "In-Memory State" +description: "Actors store state in memory for instant reads and writes. State can be persisted automatically or kept ephemeral." skill: true --- -Actors give you several places to store data. Choosing the right one keeps your actor fast, durable, and easy to reason about. +## Types of State -## Choosing Where to Store Data +There are three ways to store data in an actor, depending on what it looks like and whether it needs to survive restarts. -| Need | Use | -|---|---| -| Small, simple, serializable values (counters, flags, a small map) | `c.state` | -| Large / relational / queryable / durable data | SQLite (`c.db`) — see [SQLite docs](/docs/actors/sqlite) | -| Data in an external database, or non-serializable runtime objects (connections, clients, emitters) | `createVars` / `c.vars` | +### Durable -In-memory state (`c.state`) is the simplest option and the right default for small amounts of data. As soon as your data grows large, becomes relational, or needs to be queried, reach for [SQLite](/docs/actors/sqlite) instead. Use [ephemeral variables](#ephemeral-variables) (`c.vars`) for runtime-only objects like database clients or for loading data from an external database. +Simple, serializable data on `c.state` that is automatically persisted and restored across restarts. The default starting point. -## In-Memory State + -Actor state provides the best of both worlds: it's stored in-memory and persisted automatically. This lets you work with the data without added latency while still surviving crashes and upgrades. - -In-memory state is meant for **small, simple values** such as counters, flags, or a small map. When your data grows large or needs querying, use [SQLite](#sqlite) instead. - -### Initializing State - -There are two ways to define an actor's initial state: - - - - - -Define an actor state as a constant value: - -```typescript +```typescript Basic import { actor } from "rivetkit"; -// Simple state with a constant const counter = actor({ - // Define state as a constant + // Constant initial state state: { count: 0 }, actions: { - // ... + get: (c) => c.state.count, + + // Update state, changes are persisted automatically + increment: (c) => { + c.state.count += 1; + return c.state.count; + } } }); ``` -This value will be cloned for every new actor using `structuredClone`. - - - - - -Create actor state dynamically on each actors' creation: - -```typescript +```typescript Dynamic init import { actor } from "rivetkit"; -// State with initialization logic +interface CounterState { + count: number; +} + const counter = actor({ - // Define state using a creation function - createState: () => { - return { count: 0 }; - }, + // Compute the initial state when the actor is created + createState: (): CounterState => ({ count: 0 }), actions: { - // ... + get: (c) => c.state.count, + + increment: (c) => { + c.state.count += 1; + return c.state.count; + } } }); ``` -To accept a custom input parameters for the initial state, use: - -```typescript +```typescript With input import { actor } from "rivetkit"; -interface CounterInput { - startingCount: number; -} - interface CounterState { count: number; } -// State with initialization logic const counter = actor({ - state: { count: 0 } as CounterState, - // Define state using a creation function - createState: (c, input: CounterInput): CounterState => { - return { count: input.startingCount }; - }, + // Compute the initial state from input passed at creation + createState: (c, input: { startingCount: number }): CounterState => ({ + count: input.startingCount, + }), actions: { - increment: (c) => c.state.count++ + get: (c) => c.state.count, + + increment: (c) => { + c.state.count += 1; + return c.state.count; + } } }); ``` -Read more about [input parameters](/docs/actors/input) here. - - -If accepting arguments to `createState`, you **must** define the types: `createState(c: CreateContext, input: MyType)` + -Otherwise, the return type will not be inferred and `c.state` will be of type `unknown`. - +### Ephemeral - +Live objects on `c.vars` like database connections, API clients, and event emitters, or data loaded from an external source. Never persisted. - + -The `createState` function is called once when the actor is first created. See [Lifecycle](/docs/actors/lifecycle) for more details. - -### Modifying State - -To update state, modify the `state` property on the context object (`c.state`) in your actions: - -```typescript +```typescript Basic import { actor } from "rivetkit"; const counter = actor({ state: { count: 0 }, + // Constant ephemeral value, reset each time the actor starts + vars: { lastAccessedAt: 0 }, + actions: { - // Define action to update state increment: (c) => { - // Update state, this will automatically be persisted - c.state.count += 1; - return c.state.count; + // Read and write the ephemeral var + c.vars.lastAccessedAt = Date.now(); + return ++c.state.count; }, - add: (c, value: number) => { - c.state.count += value; - return c.state.count; - } + getLastAccessed: (c) => c.vars.lastAccessedAt } }); ``` -Only state stored in the `state` object will be persisted. Any other variables or properties outside of this are not persisted. - -### State Saves - -Actors automatically handle persisting state transparently. This happens at the end of every action if the state has changed. State is also automatically saved after `onFetch` and `onWebSocket` handlers finish executing. - -For `onWebSocket` handlers specifically, you'll need to manually save state using `c.saveState()` while the WebSocket connection is open if you want state changes to be persisted immediately. This is because WebSocket connections can remain open for extended periods, and state changes made during event handlers (like `message` events) won't be automatically saved until the connection closes. - -In other cases where you need to force a state change mid-action, you can use `c.saveState()`. This should only be used if your action makes an important state change that needs to be persisted before the action completes. - -#### Immediate vs Throttled Saves - -`c.saveState()` supports two modes: - -- **`c.saveState({ immediate: true })`** saves state to storage right away. `await` resolves once the write completes. Use this when you need to guarantee persistence before continuing (e.g. before a risky async operation). -- **`c.saveState()`** (without `immediate: true`) schedules a throttled save. `await` will not resolve until the next flush cycle, which can take up to `stateSaveInterval` (default: 10 seconds). This batches rapid state changes to reduce write frequency, but means the caller blocks until the flush fires. - -If you want to save state promptly during a WebSocket message handler, use `immediate: true`. - -```typescript +```typescript Dynamic init import { actor } from "rivetkit"; -// Mock risky operation -async function someRiskyOperation() { - await new Promise(resolve => setTimeout(resolve, 1000)); -} +const chatRoom = actor({ + state: { messages: [] as string[] }, -const criticalProcess = actor({ - state: { - steps: [] as string[], - currentStep: 0 - }, + // Build a non-serializable emitter on each start + createVars: () => ({ emitter: createEventEmitter() }), actions: { - processStep: async (c) => { - // Update to current step - c.state.currentStep += 1; - c.state.steps.push(`Started step ${c.state.currentStep}`); - - // Force save state before the async operation - await c.saveState({ immediate: true }); - - // Long-running operation that might fail - await someRiskyOperation(); - - // Update state again - c.state.steps.push(`Completed step ${c.state.currentStep}`); - - return c.state.currentStep; + broadcast: (c, text: string) => { + c.state.messages.push(text); + // Use the ephemeral emitter + c.vars.emitter.emit("message", text); } } }); -``` - -### State Isolation - -Each actor's state is completely isolated, meaning it cannot be accessed directly by other actors or clients. - -To interact with an actor's state, you must use [Actions](/docs/actors/actions). Actions provide a controlled way to read from and write to the state. - -If you need a shared state between multiple actors, see [sharing and joining state](/docs/actors/sharing-and-joining-state). - -### Type Limitations - -State is currently constrained to the following types: - -- `null` -- `undefined` -- `boolean` -- `string` -- `number` -- `BigInt` -- `Date` -- `RegExp` -- `Error` -- Typed arrays (`Uint8Array`, `Int8Array`, `Float32Array`, etc.) -- `Map` -- `Set` -- `Array` -- Plain objects - -## SQLite - -For data that is large, relational, queryable, or larger than memory, use the embedded SQLite database available on `c.db`. - -Each actor instance has its own SQLite database, scoped to that actor. Because Rivet Actors keep compute and storage together, queries avoid network round trips to an external database. SQLite stores data on disk, so you can work with datasets that do not fit in actor memory, and you get a full relational engine with tables, indexes, `JOIN`s, constraints, and transactions. - -For complete documentation, see: - -- [SQLite](/docs/actors/sqlite) — raw SQL queries against the embedded per-actor database. -- [SQLite + Drizzle](/docs/actors/sqlite-drizzle) — typed schema and query APIs with the Drizzle ORM. - -## Ephemeral Variables - -In addition to persisted state, actors can store ephemeral data that is not saved to permanent storage using `vars`. This is useful for temporary data that only needs to exist while the actor is running, non-serializable objects like database connections or event emitters, and loading initial data from an external database. - -`vars` is designed to complement `state`, not replace it. Most actors that need it will use both: `state` for critical business data and `vars` for ephemeral or non-serializable data. - -### Initializing Variables - -There are two ways to define an actor's initial vars: - - - - - -Define an actor vars as a constant value: - -```typescript -import { actor } from "rivetkit"; // Mock event emitter for demonstration interface EventEmitter { @@ -266,184 +144,304 @@ function createEventEmitter(): EventEmitter { } }; } +``` -// Define vars as a constant -const counter = actor({ - state: { count: 0 }, +```typescript @nocheck External database +import { actor } from "rivetkit"; +import { Pool } from "pg"; - // Define ephemeral variables - vars: { - lastAccessTime: 0, - emitter: createEventEmitter() +// One shared pool for the whole process, created once and reused by every actor +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + +const userActor = actor({ + state: { profile: null as Record | null }, + + // Load this actor's row from the shared pool on each start + createVars: async (c) => { + const { rows } = await pool.query("SELECT * FROM users WHERE id = $1", [c.key[0]]); + return { profile: rows[0] }; }, actions: { - increment: (c) => ++c.state.count + updateEmail: async (c, email: string) => { + await pool.query("UPDATE users SET email = $1 WHERE id = $2", [email, c.key[0]]); + } } }); ``` -This value will be cloned for every new actor using `structuredClone`. + - +### SQLite - +Rivet also provides an embedded SQLite database (`c.db`) for when your data needs to be queried, requires safe schema migrations, or grows too large to hold in memory. See [SQLite](/docs/actors/sqlite). -Create actor vars dynamically on each actors' start. Unlike `createState`, which runs only once when the actor is first created, `createVars` runs every time the actor starts. This makes it the right place to open a database client or connection and load initial data from an external source: + -```typescript +```typescript @nocheck Basic import { actor } from "rivetkit"; +import { db } from "rivetkit/db"; + +const todoList = actor({ + db: db({ + onMigrate: async (db) => { + await db.execute(` + CREATE TABLE IF NOT EXISTS todos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL + ); + `); + }, + }), -// Mock event emitter for demonstration -interface EventEmitter { - on: (event: string, callback: (data: unknown) => void) => void; - emit: (event: string, data: unknown) => void; -} + actions: { + add: async (c, title: string) => { + await c.db.execute("INSERT INTO todos (title) VALUES (?)", title); + }, -function createEventEmitter(): EventEmitter { - const listeners: Record void)[]> = {}; - return { - on: (event, callback) => { - listeners[event] = listeners[event] || []; - listeners[event].push(callback); + list: async (c) => { + return (await c.db.execute( + "SELECT id, title FROM todos ORDER BY id DESC", + )) as { id: number; title: string }[]; }, - emit: (event, data) => { - listeners[event]?.forEach(cb => cb(data)); - } - }; -} + }, +}); +``` + +```typescript @nocheck Load into memory +import { actor } from "rivetkit"; +import { db } from "rivetkit/db"; -// Define vars with initialization logic const counter = actor({ - state: { count: 0 }, + db: db({ + onMigrate: async (db) => { + await db.execute(` + CREATE TABLE IF NOT EXISTS counter ( + id INTEGER PRIMARY KEY CHECK (id = 1), + count INTEGER NOT NULL + ); + `); + await db.execute("INSERT OR IGNORE INTO counter (id, count) VALUES (1, 0)"); + }, + }), - // Define vars using a creation function - createVars: () => { - return { - lastAccessTime: Date.now(), - emitter: createEventEmitter() - }; + // Load the count from SQLite into memory on every start + createVars: async (c) => { + const rows = (await c.db.execute( + "SELECT count FROM counter WHERE id = 1", + )) as { count: number }[]; + return { count: rows[0].count }; }, actions: { - increment: (c) => ++c.state.count - } + get: (c) => c.vars.count, + + increment: async (c) => { + // Update the in-memory value and write it back to SQLite + c.vars.count += 1; + await c.db.execute("UPDATE counter SET count = ? WHERE id = 1", c.vars.count); + return c.vars.count; + }, + }, }); ``` - -If accepting arguments to `createVars`, you **must** define the types: `createVars(c: CreateVarsContext, driver: any)` + -Otherwise, the return type will not be inferred and `c.vars` will be of type `unknown`. - +## State Isolation - +Each actor's state is fully isolated. Other actors and clients can't touch it directly; all reads and writes go through the actor's own [Actions](/docs/actors/actions). To share state across actors, see [sharing and joining state](/docs/actors/sharing-and-joining-state). - +## Durable State -### Using Variables +`c.state` lives in memory and is persisted automatically, so reads and writes have no added latency while the data still survives sleeps, restarts, upgrades, and crashes. Use it for small, simple values like counters, flags, and small maps. -Vars can be accessed and modified through the context object with `c.vars`: +`createState` runs once when the actor is first created. On later starts, state is loaded from storage instead of recreated. See [Lifecycle](/docs/actors/lifecycle). + +### When state saves + +Mutating `c.state` schedules a save automatically. Rapid mutations are batched into a single write on a throttle (`stateSaveInterval`, default 1 second). Reads never trigger a save, saves aren't tied to action or handler boundaries, and state is also flushed when the actor sleeps or shuts down. + +To force a save mid-action, call `c.saveState()`: + +- `c.saveState({ immediate: true })` writes immediately and resolves once the write completes. +- `c.saveState()` schedules a throttled save and returns right away, without waiting for the write. + +Force an immediate save before a risky side effect so a crash can't lose progress: ```typescript import { actor } from "rivetkit"; -// Mock event emitter for demonstration -interface EventEmitter { - on: (event: string, callback: (data: number) => void) => void; - emit: (event: string, data: number) => void; -} +const checkout = actor({ + state: { status: "pending" as "pending" | "charged" | "fulfilled" }, -function createEventEmitter(): EventEmitter { - const listeners: Record void)[]> = {}; - return { - on: (event, callback) => { - listeners[event] = listeners[event] || []; - listeners[event].push(callback); - }, - emit: (event, data) => { - listeners[event]?.forEach(cb => cb(data)); + actions: { + fulfill: async (c) => { + c.state.status = "charged"; + // Persist before the side effect so a crash can't undo it + await c.saveState({ immediate: true }); + + await chargeExternalProvider(); + + c.state.status = "fulfilled"; + return c.state.status; } - }; + } +}); + +async function chargeExternalProvider() { + await new Promise((resolve) => setTimeout(resolve, 100)); } +``` -const counter = actor({ - // Persistent state - saved to storage - state: { count: 0 }, +### Supported types - // Create ephemeral objects that won't be serialized - createVars: () => { - // Create an event emitter (can't be serialized) - const emitter = createEventEmitter(); +State must be serializable. - // Set up event listener directly in createVars - emitter.on('count-changed', (newCount) => { - console.log(`Count changed to: ${newCount}`); - }); + + - return { emitter }; - }, +- `null`, `undefined`, `boolean`, `string`, `number`, `BigInt` +- `Date`, `RegExp`, `Error` +- `ArrayBuffer` and typed arrays (`Uint8Array`, `Int8Array`, `Float32Array`, etc.) +- `Map`, `Set`, `Array` +- Plain objects - actions: { - increment: (c) => { - // Update persistent state - c.state.count += 1; + + - // Use non-serializable emitter - c.vars.emitter.emit('count-changed', c.state.count); +When data grows large or needs querying, store it in [Embedded SQLite](#embedded-sqlite) instead. - return c.state.count; +## Ephemeral State + +`c.vars` holds data that exists only while the actor runs and is never saved. Use it for live objects that can't be serialized (connections, clients, emitters) or for data loaded from an external source. Most actors use both: `state` for durable data, `vars` for live objects. + +`createVars` runs on every actor start, unlike `createState` which runs once. That makes it the place to open connections and load data each time the actor wakes. + +### Runtime objects + +Build non-serializable objects in `createVars` and use them from actions: + +```typescript +import { actor } from "rivetkit"; + +const room = actor({ + state: { messages: [] as string[] }, + + // EventTarget can't be serialized, so it lives in vars + createVars: () => ({ events: new EventTarget() }), + + actions: { + send: (c, text: string) => { + c.state.messages.push(text); + c.vars.events.dispatchEvent(new CustomEvent("message", { detail: text })); } } }); ``` -### Connecting to External Databases +### Loading from external sources -Because `createVars` runs on every actor start, it's the natural place to open a connection to an external database such as Postgres or Redis and load any data your actor needs. The connection lives only in memory and is never serialized: +Create the connection pool once at module scope and share it across all actors, then use `createVars` (which can be `async`) to load this actor's data from it on each start: ```typescript @nocheck import { actor } from "rivetkit"; import { Pool } from "pg"; -const userActor = actor({ - state: { profile: null as Record | null }, +// One shared pool for the whole process, not one per actor +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + +const profile = actor({ + state: { cachedName: "" }, - // Open a connection to the external database and load initial data on every start createVars: async (c) => { - const pool = new Pool({ connectionString: process.env.DATABASE_URL }); - const result = await pool.query("SELECT * FROM users WHERE id = $1", [c.key[0]]); - return { pool, profile: result.rows[0] }; + const { rows } = await pool.query("SELECT * FROM users WHERE id = $1", [c.key[0]]); + return { user: rows[0] }; }, actions: { updateEmail: async (c, email: string) => { - await c.vars.pool.query("UPDATE users SET email = $1 WHERE id = $2", [email, c.key[0]]); + await pool.query("UPDATE users SET email = $1 WHERE id = $2", [email, c.key[0]]); } } }); ``` -Use this pattern when your source of truth lives in an external database. For data owned entirely by the actor, prefer [in-memory state](#in-memory-state) or [SQLite](#sqlite), which require no external infrastructure. +When the actor owns its data, prefer [durable state](#durable-state) or [SQLite](#embedded-sqlite), which need no external infrastructure. + +### Cleanup + +`vars` is dropped when the actor stops, but per-actor resources like timers, subscriptions, and dedicated connections aren't cleaned up for you. Release them in `onSleep` and `onDestroy`. A shared pool stays open for the whole process, so don't close it per actor. + +```typescript @nocheck +const poller = actor({ + state: { ticks: 0 }, + + // Per-actor timer started on each wake + createVars: (c) => ({ timer: setInterval(() => c.state.ticks++, 5000) }), + + // Clear it before the actor sleeps or is destroyed + onSleep: (c) => clearInterval(c.vars.timer), + onDestroy: (c) => clearInterval(c.vars.timer), + + actions: { /* ... */ } +}); +``` -### When to Use `vars` vs `state` +## Embedded SQLite -In practice, most actors that need both will use them together: `state` for critical business data and `vars` for ephemeral or non-serializable data. +`c.db` is a SQLite database scoped to each actor and stored on disk. Use it for queryable, relational, or larger-than-memory data. Because compute and storage live together, queries run locally with no network round trips. -Use `vars` when: +A common pattern is to treat SQLite as the source of truth and keep a working copy in `c.vars`: load rows in `createVars`, serve reads from memory, and write changes back to `c.db`. -- You need to store temporary data that doesn't need to survive restarts. -- You need to maintain runtime-only references that can't be serialized (database connections, event emitters, class instances, etc.). -- You need to load data from or write through to an external database. +```typescript @nocheck +import { actor } from "rivetkit"; +import { db } from "rivetkit/db"; + +const leaderboard = actor({ + db: db({ + onMigrate: async (db) => { + await db.execute(` + CREATE TABLE IF NOT EXISTS scores ( + player TEXT PRIMARY KEY, + score INTEGER NOT NULL + ); + `); + }, + }), + + // Load the table into memory once per start + createVars: async (c) => { + const rows = (await c.db.execute("SELECT player, score FROM scores")) as { + player: string; + score: number; + }[]; + return { scores: new Map(rows.map((r) => [r.player, r.score])) }; + }, + + actions: { + top: (c) => [...c.vars.scores].sort((a, b) => b[1] - a[1]).slice(0, 10), + + record: async (c, player: string, score: number) => { + c.vars.scores.set(player, score); + // Write through to SQLite + await c.db.execute( + "INSERT INTO scores (player, score) VALUES (?, ?) ON CONFLICT(player) DO UPDATE SET score = ?", + player, score, score, + ); + }, + }, +}); +``` -Use `state` when: +For the full query API, schema migrations, transactions, and the Drizzle ORM, see: -- The data must be preserved across actor sleeps, restarts, updates, or crashes. -- The information is essential to the actor's core functionality and business logic. +- [SQLite](/docs/actors/sqlite): raw SQL against the embedded per-actor database. +- [SQLite + Drizzle](/docs/actors/sqlite-drizzle): typed schema and query APIs. ## Debugging -- `GET /inspector/state` returns the actor's current persisted state and `isStateEnabled`. +- `GET /inspector/state` returns the actor's current state and `isStateEnabled`. - `PATCH /inspector/state` lets you set state directly while debugging. - In non-dev mode, inspector endpoints require authorization. diff --git a/website/src/content/docs/actors/statuses.mdx b/website/src/content/docs/actors/statuses.mdx index a61380f6a7..3579aa4988 100644 --- a/website/src/content/docs/actors/statuses.mdx +++ b/website/src/content/docs/actors/statuses.mdx @@ -10,17 +10,17 @@ These are the statuses you can see in the dashboard for each actor. | Status | Description | |---|---| -| **Starting** | The actor has been created and a runner has been allocated, but the actor process has not yet reported that it is ready. | +| **Starting** | The actor has been created but has not yet become connectable. | | **Running** | The actor is live and accepting connections. | -| **Stopped** | The actor has been gracefully destroyed. | +| **Destroyed** | The actor has been gracefully destroyed. | | **Crashed** | The actor failed to start or encountered a fatal error. See [Troubleshooting](/docs/actors/troubleshooting#actor-status-is-crashed) for common failure reasons. | | **Sleeping** | The actor has been put to sleep from inactivity. It will be woken up automatically when a new request arrives. | | **Pending** | The actor is waiting to be allocated to a runner. This happens when no runner is available to handle the actor. See [Troubleshooting](/docs/actors/troubleshooting#actor-status-is-pending) for common causes. | -| **Crash-Loop** | The actor failed to allocate and is waiting to retry with a backoff. This typically means repeated allocation failures. The backoff prevents overloading your infrastructure in the case of a widespread misconfiguration in your backend. See [Troubleshooting](/docs/actors/troubleshooting#actor-status-is-crashed) for common failure reasons. | +| **Crash Loop Backoff** | The actor failed to allocate and is waiting to retry with a backoff. This typically means repeated allocation failures. The backoff prevents overloading your infrastructure in the case of a widespread misconfiguration in your backend. See [Troubleshooting](/docs/actors/troubleshooting#actor-status-is-crashed) for common failure reasons. | ## API Representation -The actor object returned by the API includes the following timestamp fields used to derive status: +The actor object returned by the full engine API (used by the dashboard) includes the following timestamp fields used to derive status: | Field | Description | |---|---| diff --git a/website/src/content/docs/actors/testing.mdx b/website/src/content/docs/actors/testing.mdx index 4666b4da69..bce806032b 100644 --- a/website/src/content/docs/actors/testing.mdx +++ b/website/src/content/docs/actors/testing.mdx @@ -18,7 +18,7 @@ npm test ## Basic Testing Setup -Rivet includes a test helper called `setupTest` that configures a test environment with in-memory drivers for your actors. This allows for fast, isolated tests without external dependencies. +Rivet includes a test helper called `setupTest` that starts your registry in test mode and returns a client connected to it. This allows for fast, isolated tests without external dependencies. ```ts import { test, expect } from "vitest"; @@ -49,7 +49,7 @@ test("my actor test", async (testCtx) => { const { client } = await setupTest(testCtx, registry); // Now you can interact with your actor through the client - const myActorHandle = client.myActor.get(["test"]); + const myActorHandle = client.myActor.getOrCreate(["test"]); // Test your actor's functionality await myActorHandle.someAction(); @@ -62,7 +62,7 @@ test("my actor test", async (testCtx) => { ## Testing Actor State -The test framework uses in-memory drivers that persist state within each test, allowing you to verify that your actor correctly maintains state between operations. +State persists within each test, allowing you to verify that your actor correctly maintains state between operations. ```ts import { test, expect } from "vitest"; @@ -92,7 +92,7 @@ const registry = setup({ // Test state persistence test("actor should persist state", async (testCtx) => { const { client } = await setupTest(testCtx, registry); - const counterHandle = client.counter.get(["test"]); + const counterHandle = client.counter.getOrCreate(["test"]); // Initial state expect(await counterHandle.getCount()).toBe(0); @@ -143,7 +143,7 @@ const registry = setup({ // Test event emission test("actor should emit events", async (testCtx) => { const { client } = await setupTest(testCtx, registry); - const chatRoomHandle = client.chatRoom.get(["test"]); + const chatRoomHandle = client.chatRoom.getOrCreate(["test"]); // Set up event handler with a mock function const mockHandler = vi.fn(); @@ -162,13 +162,16 @@ test("actor should emit events", async (testCtx) => { ## Testing Schedules -Rivet's schedule functionality can be tested using Vitest's time manipulation utilities: +Rivet's schedule functionality can be tested by scheduling work and waiting for it to run: ```ts -import { test, expect, vi } from "vitest"; +import { test, expect } from "vitest"; import { setupTest } from "rivetkit/test"; import { actor, setup } from "rivetkit"; +// Helper to wait for a delay +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + // Define the scheduler actor const scheduler = actor({ state: { @@ -200,32 +203,31 @@ const registry = setup({ // Test scheduled tasks test("scheduled tasks should execute", async (testCtx) => { - // setupTest automatically configures vi.useFakeTimers() const { client } = await setupTest(testCtx, registry); - const schedulerHandle = client.scheduler.get(["test"]); + const schedulerHandle = client.scheduler.getOrCreate(["test"]); // Set up a scheduled task - await schedulerHandle.scheduleTask("reminder", 60000); // 1 minute in the future + await schedulerHandle.scheduleTask("reminder", 100); // 100ms in the future - // Fast-forward time by 1 minute - await vi.advanceTimersByTimeAsync(60000); + // Wait for the scheduled task to run + await wait(150); // Verify the scheduled task executed expect(await schedulerHandle.getCompletedTasks()).toContain("reminder"); }); ``` -The `setupTest` function automatically calls `vi.useFakeTimers()`, allowing you to control time in your tests with functions like `vi.advanceTimersByTimeAsync()`. This makes it possible to test scheduled operations without waiting for real time to pass. +Use a short real-time delay to wait for scheduled work to run. `setupTest` does not install fake timers, so if you want to use `vi.useFakeTimers()` you must enable it yourself and confirm it works with your selected runtime. ## Best Practices 1. **Isolate tests**: Each test should run independently, avoiding shared state. 2. **Test edge cases**: Verify how your actor handles invalid inputs, concurrent operations, and error conditions. -3. **Mock time**: Use Vitest's timer mocks for testing scheduled operations. +3. **Test scheduled operations**: Use short real-time delays to wait for scheduled work to run. 4. **Use realistic data**: Test with data that resembles production scenarios. -Rivet's testing framework automatically handles server setup and teardown, so you can focus on writing effective tests for your business logic. +`setupTest` starts the registry and disposes the returned client when the test finishes, so you can focus on writing effective tests for your business logic. ## API Reference -- [`test`](/typedoc/functions/rivetkit.mod.test.html) - Test helper function +- [`setupTest`](/typedoc/functions/rivetkit.test_mod.setupTest.html) - Test setup helper function diff --git a/website/src/content/docs/actors/troubleshooting.mdx b/website/src/content/docs/actors/troubleshooting.mdx index ed684346eb..a4cf9002cc 100644 --- a/website/src/content/docs/actors/troubleshooting.mdx +++ b/website/src/content/docs/actors/troubleshooting.mdx @@ -65,6 +65,22 @@ The server running your actor lost its connection to Rivet. This is usually caus Your server is shutting down and the actor did not finish in time. Consider handling graceful shutdown in your actor or increasing your shutdown timeout. +### `concurrent_actor_limit_reached` + +The actor could not be allocated because the concurrent actor limit was reached. Reduce the number of concurrently running actors or increase your limit. + +### `no_envoys` + +No server was available to run your actor. This is equivalent to `no_capacity` on the current allocation path. See the `no_capacity` section above for the causes and fixes for your [runtime mode](/docs/general/runtime-modes). + +### `envoy_no_response` + +The server running your actor did not respond in time. This can happen if your server is overloaded or experienced a network issue. Try restarting your server or checking its health. + +### `envoy_connection_lost` + +The server running your actor lost its connection to Rivet. This is usually caused by a network interruption or your server restarting. + ### `serverless_http_error` Your serverless endpoint returned an HTTP error. Common causes: @@ -116,7 +132,7 @@ Without versioning, Rivet has no way to distinguish old deployments from new one - **Serverless**: Old requests may still be open from the previous deployment, so actors continue running on the old version's connection until those requests close. - **Runners**: The old runner container is still running and will continue accepting new actors. New actors may be scheduled on the old runner instead of the new one. -To fix this, configure a version number in your [registry configuration](/docs/connect/registry-configuration). When a new version is deployed, Rivet will allocate new actors to the latest version and optionally drain old actors to migrate them. +To fix this, configure a version number in your [registry configuration](/docs/general/registry-configuration). When a new version is deployed, Rivet will allocate new actors to the latest version and optionally drain old actors to migrate them. ## Actor status is pending diff --git a/website/src/content/docs/actors/types.mdx b/website/src/content/docs/actors/types.mdx index 2b6e5a5d93..7c5d3a72a5 100644 --- a/website/src/content/docs/actors/types.mdx +++ b/website/src/content/docs/actors/types.mdx @@ -12,8 +12,6 @@ Context types define what properties and methods are available in different part import { actor } from "rivetkit"; const counter = actor({ - state: { count: 0 }, - // CreateContext in createState hook createState: (c, input: { initial: number }) => { return { count: input.initial }; @@ -37,14 +35,9 @@ When writing helper functions that work with actor contexts, use context extract import { actor, CreateContextOf, ActionContextOf } from "rivetkit"; const gameRoom = actor({ - state: { - players: [] as string[], - score: 0 - }, - createState: (c, input: { roomId: string }) => { initializeRoom(c, input.roomId); - return { players: [], score: 0 }; + return { players: [] as string[], score: 0 }; }, actions: { diff --git a/website/src/content/docs/actors/versions.mdx b/website/src/content/docs/actors/versions.mdx index 400dc34504..ae936241b5 100644 --- a/website/src/content/docs/actors/versions.mdx +++ b/website/src/content/docs/actors/versions.mdx @@ -13,7 +13,7 @@ Each runner has a **version number**. When you deploy new code with a new versio - **Drain old actors**: When enabled, a runner connecting with a newer version number will gracefully stop old actors to be rescheduled to the new version -Versions are not configured by default. See [Registry Configuration](/docs/connect/registry-configuration) to learn how to configure the runner version. +Versions are not configured by default. See [Registry Configuration](/docs/general/registry-configuration) to learn how to configure the runner version. @@ -26,7 +26,7 @@ Versions are not configured by default. See [Registry Configuration](/docs/conne -When a new version is deployed, existing actors are immediately drained from the old runner and live migrated to the new version. +When a new version is deployed, existing actors are gracefully stopped on the old runner and rescheduled onto the new version. ```mermaid sequenceDiagram @@ -36,8 +36,8 @@ sequenceDiagram Note over R1: Currently running Note over R2: Deployed R2->>R1: Drain old actors - R1->>R2: Live migrate actors - Note over R1: Shut down when all actors migrated + R1->>R2: Reschedule actors + Note over R1: Shut down when all actors stopped ``` @@ -214,8 +214,8 @@ The `drainOnVersionUpgrade` option controls whether old actors are stopped when | Value | Behavior | |-------|----------| -| `false` (default in [runner mode](/docs/general/runtime-modes)) | Old actors continue running. New actors go to new version. Versions coexist. | -| `true` (default in [serverless mode](/docs/general/runtime-modes)) | Old actors receive stop signal and have 30m to finish gracefully. | +| `false` | Old actors continue running. New actors go to new version. Versions coexist. | +| `true` (default) | Old actors receive stop signal and have 30m to finish gracefully. | ## Upgrading Actor State diff --git a/website/src/content/docs/actors/websocket-handler.mdx b/website/src/content/docs/actors/websocket-handler.mdx index 1e3e13305d..073cca359a 100644 --- a/website/src/content/docs/actors/websocket-handler.mdx +++ b/website/src/content/docs/actors/websocket-handler.mdx @@ -45,7 +45,7 @@ See also the [raw WebSocket handler example](https://github.com/rivet-dev/rivet/ ### Via RivetKit Client -Use the `.websocket()` method on an actor handle to open a WebSocket connection to the actor's `onWebSocket` handler. This can be executed from either your frontend or backend. +Use the `.webSocket()` method on an actor handle to open a WebSocket connection to the actor's `onWebSocket` handler. This can be executed from either your frontend or backend. ```typescript index.ts @hide @nocheck @@ -87,7 +87,7 @@ ws.send(JSON.stringify({ type: "chat", text: "Hello!" })); ``` -The `.websocket()` method returns a standard WebSocket. +The `.webSocket()` method returns a standard WebSocket. ### Via getGatewayUrl @@ -206,6 +206,12 @@ import { actor, setup } from "rivetkit"; const chatActor = actor({ state: { messages: [] as string[] }, + onWebSocket: (c, websocket) => { + websocket.addEventListener("message", (event) => { + c.state.messages.push(event.data as string); + websocket.send(event.data as string); + }); + }, actions: {} }); diff --git a/website/src/content/docs/actors/workflows.mdx b/website/src/content/docs/actors/workflows.mdx index 164b81e8a6..4d7e262055 100644 --- a/website/src/content/docs/actors/workflows.mdx +++ b/website/src/content/docs/actors/workflows.mdx @@ -598,6 +598,8 @@ console.log(await handle.getState()); Use step timeouts and retries for slow or flaky dependencies. +Step timeouts are critical by default and fail immediately. Set `retryOnTimeout: true` if a timeout should retry like any other error using `maxRetries`. + ```ts import { actor, queue, setup } from "rivetkit"; import { type WorkflowContextOf, type WorkflowLoopContextOf, type WorkflowBranchContextOf, workflow } from "rivetkit/workflow"; @@ -620,6 +622,7 @@ export const timeoutActor = actor({ const chargeId = await loopCtx.step({ name: "charge-card", timeout: 5_000, + retryOnTimeout: true, maxRetries: 5, retryBackoffBase: 200, retryBackoffMax: 2_000, @@ -1573,8 +1576,10 @@ export const checkoutSagaActor = actor({ await loopCtx.step({ name: "reserve-inventory", run: async () => reserveInventoryForCheckout(loopCtx, checkout.orderId), + // Rollback callbacks only receive a rollback context, not actor + // APIs like client(). Compensate with direct external calls. rollback: async (_rollbackCtx, output) => { - await releaseInventoryForCheckout(loopCtx, output as string); + await releaseInventoryForCheckout(output as string); }, }); @@ -1582,7 +1587,7 @@ export const checkoutSagaActor = actor({ name: "charge-card", run: async () => chargeCheckout(loopCtx, checkout.amount), rollback: async (_rollbackCtx, output) => { - await refundCheckout(loopCtx, output as string); + await refundCheckout(output as string); }, }); @@ -1605,12 +1610,12 @@ async function reserveInventoryForCheckout( } async function releaseInventoryForCheckout( - ctx: WorkflowLoopContextOf, reservationId: string, ): Promise { - const client = ctx.client(); - const inventory = client.inventoryActor.getOrCreate(["main"]); - await inventory.release(reservationId); + await fetch("https://api.example.com/inventory/release", { + method: "POST", + body: JSON.stringify({ reservationId }), + }); } async function chargeCheckout( @@ -1623,12 +1628,12 @@ async function chargeCheckout( } async function refundCheckout( - ctx: WorkflowLoopContextOf, chargeId: string, ): Promise { - const client = ctx.client(); - const billing = client.billingActor.getOrCreate(["main"]); - await billing.refund(chargeId); + await fetch("https://api.example.com/billing/refund", { + method: "POST", + body: JSON.stringify({ chargeId }), + }); } function markOrderComplete( @@ -1724,12 +1729,13 @@ export const pollBackoffActor = actor({ return; } - await loopCtx.step("grow-backoff", async () => { + const retryDelay = await loopCtx.step("grow-backoff", async () => { loopCtx.state.status = "retrying"; loopCtx.state.backoffMs = Math.min(loopCtx.state.backoffMs * 2, 5_000); + return loopCtx.state.backoffMs; }); - await loopCtx.sleep("retry-delay", loopCtx.state.backoffMs); + await loopCtx.sleep("retry-delay", retryDelay); }); }), actions: { diff --git a/website/src/content/docs/connect/freestyle.mdx b/website/src/content/docs/connect/freestyle.mdx index 6db6b40bff..09a60e74ff 100644 --- a/website/src/content/docs/connect/freestyle.mdx +++ b/website/src/content/docs/connect/freestyle.mdx @@ -107,27 +107,31 @@ Run this deployment script to push your application to Freestyle. Update the runner configuration on the Rivet side to connect with your Freestyle deployment. Create a configuration script and run it after your Freestyle deployment is live: ```typescript @nocheck -import { RivetClient } from "rivetkit/client"; +import { RivetClient } from "@rivetkit/engine-api-full"; const rivet = new RivetClient({ - endpoint: "api.rivet.dev", + environment: "https://api.rivet.dev", token: process.env.RIVET_API_TOKEN, }); const FREESTYLE_DOMAIN = "my-domain.style.dev"; // Change to your desired Freestyle domain const RIVET_NAMESPACE = "my-rivet-namespace"; // Change to your Rivet namespace -await rivet.runnerConfigs.upsert("freestyle-runner", { - serverless: { - url: `https://${FREESTYLE_DOMAIN}/start`, - runnersMargin: 1, - minRunners: 1, - maxRunners: 1, - slotsPerRunner: 1, - // Must be shorter than Freestyle request `timeout` config - requestLifespan: 60 * 5 - 5, - }, +await rivet.runnerConfigsUpsert("freestyle-runner", { namespace: RIVET_NAMESPACE, + datacenters: { + default: { + serverless: { + url: `https://${FREESTYLE_DOMAIN}/start`, + runnersMargin: 1, + minRunners: 1, + maxRunners: 1, + slotsPerRunner: 1, + // Must be shorter than Freestyle request `timeout` config + requestLifespan: 60 * 5 - 5, + }, + }, + }, }); ``` diff --git a/website/src/content/docs/general/pool-configuration.mdx b/website/src/content/docs/general/pool-configuration.mdx index 6a15088d36..780af355e8 100644 --- a/website/src/content/docs/general/pool-configuration.mdx +++ b/website/src/content/docs/general/pool-configuration.mdx @@ -17,7 +17,7 @@ Configure a pool via the dashboard, the API directly, or the TypeScript SDK: -```typescript SDK +```typescript SDK @nocheck import { RivetClient } from "@rivetkit/engine-api-full"; const rivet = new RivetClient({ @@ -28,7 +28,7 @@ const rivet = new RivetClient({ await rivet.runnerConfigsUpsert("default", { namespace: "default", datacenters: { - "us-east-1": { + default: { serverless: { url: "https://my-app.example.com/api/rivet", requestLifespan: 60 * 15, diff --git a/website/src/sitemap/mod.ts b/website/src/sitemap/mod.ts index e64e4a7810..5fac6910b7 100644 --- a/website/src/sitemap/mod.ts +++ b/website/src/sitemap/mod.ts @@ -137,7 +137,13 @@ export const sitemap = [ title: "Rust", href: "/docs/actors/quickstart/rust", icon: faRust, - badge: "Preview", + badge: "Beta", + }, + { + title: "Effect.ts", + href: "/docs/actors/quickstart/effect", + icon: faLayerGroup, + badge: "Beta", }, ], }, @@ -206,17 +212,6 @@ export const sitemap = [ // }, ] }, - { - title: "Extensions", - pages: [ - { - title: "Sandbox Actor", - href: "/docs/actors/sandbox", - icon: faSquareTerminal, - badge: "Beta", - }, - ] - }, { title: "Concepts", pages: [ @@ -335,10 +330,6 @@ export const sitemap = [ title: "Custom Inspector Tabs", href: "/docs/actors/inspector-tabs", }, - { - title: "AI & User-Generated Actors", - href: "/docs/actors/ai-and-user-generated-actors", - }, { title: "Types", href: "/docs/actors/types", @@ -421,6 +412,10 @@ export const sitemap = [ title: "Configuration", collapsible: true, pages: [ + { + title: "Runtime Modes", + href: "/docs/general/runtime-modes", + }, { title: "Registry Configuration", href: "/docs/general/registry-configuration", @@ -433,10 +428,6 @@ export const sitemap = [ title: "Environment Variables", href: "/docs/general/environment-variables", }, - { - title: "Runtime Modes", - href: "/docs/general/runtime-modes", - }, { title: "HTTP Server", href: "/docs/general/http-server",