From 006415f708afe36aefc3732ebc1f341ef85cbb8d Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 12 Jun 2026 13:17:09 -0700 Subject: [PATCH 01/10] [SLOP(claude-opus-4-8-medium)] fix(rivetkit-wasm): add missing inspector_tabs field to ActorConfigInput conversion --- rivetkit-typescript/packages/rivetkit-wasm/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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, } } From a8e58ed102483eccb7ba9452ccd1ddfbfebdb7cb Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 12 Jun 2026 13:19:59 -0700 Subject: [PATCH 02/10] [SLOP(claude-opus-4-8-medium)] docs(quickstart): clean up rust quickstart to import shared actor and auto-download engine --- .../content/docs/actors/quickstart/rust.mdx | 126 +++++++++--------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/website/src/content/docs/actors/quickstart/rust.mdx b/website/src/content/docs/actors/quickstart/rust.mdx index e411f7ef60..dc106d5859 100644 --- a/website/src/content/docs/actors/quickstart/rust.mdx +++ b/website/src/content/docs/actors/quickstart/rust.mdx @@ -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 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. 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. @@ -276,3 +259,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. + + From e1326aae68f5241ca3fac9d74ae9bb6914bf2b13 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 12 Jun 2026 13:26:25 -0700 Subject: [PATCH 03/10] [SLOP(claude-opus-4-8-medium)] docs(pool-configuration): fix runner-config SDK snippet to use RivetClient from engine-api-full --- .../src/content/docs/connect/freestyle.mdx | 28 +++++++++++-------- .../docs/general/pool-configuration.mdx | 4 +-- 2 files changed, 18 insertions(+), 14 deletions(-) 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, From c3fbd9f00726c25d06f6a46e3132e43b45cf1655 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 12 Jun 2026 15:20:06 -0700 Subject: [PATCH 04/10] [SLOP(claude-opus-4-8)] docs(actors): rewrite in-memory state page, remove sandbox page, fix stateSaveInterval default, forbid em dashes --- CLAUDE.md | 2 +- website/src/content/docs/actors/lifecycle.mdx | 6 +- website/src/content/docs/actors/limits.mdx | 2 +- website/src/content/docs/actors/sandbox.mdx | 24 - website/src/content/docs/actors/state.mdx | 555 +++++++++--------- website/src/sitemap/mod.ts | 11 - 6 files changed, 278 insertions(+), 322 deletions(-) delete mode 100644 website/src/content/docs/actors/sandbox.mdx diff --git a/CLAUDE.md b/CLAUDE.md index 30d98141df..71fe123a19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -336,7 +336,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/website/src/content/docs/actors/lifecycle.mdx b/website/src/content/docs/actors/lifecycle.mdx index 88ae401359..ccccf82ec4 100644 --- a/website/src/content/docs/actors/lifecycle.mdx +++ b/website/src/content/docs/actors/lifecycle.mdx @@ -913,8 +913,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 +943,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..7da039d792 100644 --- a/website/src/content/docs/actors/limits.mdx +++ b/website/src/content/docs/actors/limits.mdx @@ -133,7 +133,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/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/state.mdx b/website/src/content/docs/actors/state.mdx index 72a22beb00..32636bd6bf 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. +## Durable vs Ephemeral -## 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,154 +144,215 @@ 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() +const userActor = actor({ + state: { profile: null as Record | null }, + + // Open a connection 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] }; }, actions: { - increment: (c) => ++c.state.count + updateEmail: async (c, email: string) => { + await c.vars.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)` + + +## 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 + +`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. + +`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). -Otherwise, the return type will not be inferred and `c.vars` will be of type `unknown`. - +### 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()`: -### Using Variables +- `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. -Vars can be accessed and modified through the context object with `c.vars`: +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` +- 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: +`createVars` can be `async`, so open a connection and load initial data on each start. The connection lives only in memory: ```typescript @nocheck import { actor } from "rivetkit"; import { Pool } from "pg"; -const userActor = actor({ - state: { profile: null as Record | null }, +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 { pool, user: rows[0] }; }, actions: { @@ -424,22 +363,74 @@ const userActor = actor({ }); ``` -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 external resources are not closed for you. Release them in `onSleep` and `onDestroy`: + +```typescript @nocheck +const profile = actor({ + createVars: () => ({ pool: new Pool() }), + + // Close the connection before the actor sleeps or is destroyed + onSleep: (c) => c.vars.pool.end(), + onDestroy: (c) => c.vars.pool.end(), + + 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 diff --git a/website/src/sitemap/mod.ts b/website/src/sitemap/mod.ts index e64e4a7810..1eecc53763 100644 --- a/website/src/sitemap/mod.ts +++ b/website/src/sitemap/mod.ts @@ -206,17 +206,6 @@ export const sitemap = [ // }, ] }, - { - title: "Extensions", - pages: [ - { - title: "Sandbox Actor", - href: "/docs/actors/sandbox", - icon: faSquareTerminal, - badge: "Beta", - }, - ] - }, { title: "Concepts", pages: [ From b87e26272a2a2db80ffe7a0a3d6beb550dc4ebf5 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 12 Jun 2026 15:56:54 -0700 Subject: [PATCH 05/10] [SLOP(claude-opus-4-8)] docs(actors): apply docs audit fixes --- .../content/docs/actors/access-control.mdx | 4 +- website/src/content/docs/actors/actions.mdx | 1 - .../actors/ai-and-user-generated-actors.mdx | 301 ------------------ .../src/content/docs/actors/appearance.mdx | 4 +- .../content/docs/actors/authentication.mdx | 6 +- .../src/content/docs/actors/connections.mdx | 10 +- website/src/content/docs/actors/debugging.mdx | 86 ++--- .../content/docs/actors/design-patterns.mdx | 8 +- website/src/content/docs/actors/destroy.mdx | 2 +- website/src/content/docs/actors/errors.mdx | 13 +- website/src/content/docs/actors/events.mdx | 8 +- website/src/content/docs/actors/index.mdx | 20 +- website/src/content/docs/actors/input.mdx | 15 +- .../content/docs/actors/inspector-tabs.mdx | 10 +- website/src/content/docs/actors/keys.mdx | 2 - website/src/content/docs/actors/kv.mdx | 34 +- website/src/content/docs/actors/lifecycle.mdx | 42 ++- website/src/content/docs/actors/limits.mdx | 16 +- website/src/content/docs/actors/metadata.mdx | 2 +- website/src/content/docs/actors/queues.mdx | 4 +- .../docs/actors/quickstart/backend.mdx | 6 + .../docs/actors/quickstart/next-js.mdx | 11 +- .../content/docs/actors/quickstart/rust.mdx | 20 +- .../content/docs/actors/request-handler.mdx | 13 +- website/src/content/docs/actors/schedule.mdx | 4 +- .../content/docs/actors/sqlite-drizzle.mdx | 11 +- website/src/content/docs/actors/sqlite.mdx | 1 - website/src/content/docs/actors/state.mdx | 4 +- website/src/content/docs/actors/statuses.mdx | 8 +- website/src/content/docs/actors/testing.mdx | 34 +- .../content/docs/actors/troubleshooting.mdx | 18 +- website/src/content/docs/actors/types.mdx | 9 +- website/src/content/docs/actors/versions.mdx | 12 +- .../content/docs/actors/websocket-handler.mdx | 10 +- website/src/content/docs/actors/workflows.mdx | 30 +- website/src/sitemap/mod.ts | 4 - 36 files changed, 254 insertions(+), 529 deletions(-) delete mode 100644 website/src/content/docs/actors/ai-and-user-generated-actors.mdx 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 ccccf82ec4..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() }), diff --git a/website/src/content/docs/actors/limits.mdx b/website/src/content/docs/actors/limits.mdx index 7da039d792..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, + }, // ... }); ``` 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/next-js.mdx b/website/src/content/docs/actors/quickstart/next-js.mdx index 4bdcbc564c..f1ec9f85df 100644 --- a/website/src/content/docs/actors/quickstart/next-js.mdx +++ b/website/src/content/docs/actors/quickstart/next-js.mdx @@ -70,7 +70,7 @@ import { registry } from "@/rivet/registry"; export const maxDuration = 300; -export const { GET, POST, PUT, PATCH, HEAD, OPTIONS } = toNextHandler(registry); +export const { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS } = toNextHandler(registry); ``` @@ -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 dc106d5859..2958a6b912 100644 --- a/website/src/content/docs/actors/quickstart/rust.mdx +++ b/website/src/content/docs/actors/quickstart/rust.mdx @@ -201,14 +201,13 @@ 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); -}); - -await connection.increment(1); +await counter.connect().increment(1); ``` + +Events emitted by a Rust actor with `ctx.emit(...)` are broadcast as a single serialized struct value. The TypeScript and React clients deliver event arguments positionally, so consuming a Rust struct event from JavaScript is not supported yet. Call actions across languages, and subscribe to events from Rust clients. + + See the [JavaScript client documentation](/docs/clients/javascript) for more information. @@ -229,10 +228,9 @@ function Counter() { key: ["my-counter"], }); - counter.useEvent("newCount", (event: { count: number }) => setCount(event.count)); - const increment = async () => { - await counter.connection?.increment(1); + const next = await counter.connection?.increment(1); + if (typeof next === "number") setCount(next); }; return ( @@ -244,6 +242,10 @@ function Counter() { } ``` + +Events emitted by a Rust actor with `ctx.emit(...)` are broadcast as a single serialized struct value. The TypeScript and React clients deliver event arguments positionally, so `useEvent` cannot consume a Rust struct event yet. This example reads the action return value instead. + + See the [React documentation](/docs/clients/react) for more information. 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/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 32636bd6bf..98661fa864 100644 --- a/website/src/content/docs/actors/state.mdx +++ b/website/src/content/docs/actors/state.mdx @@ -301,7 +301,7 @@ State must be serializable. - `null`, `undefined`, `boolean`, `string`, `number`, `BigInt` - `Date`, `RegExp`, `Error` -- Typed arrays (`Uint8Array`, `Int8Array`, `Float32Array`, etc.) +- `ArrayBuffer` and typed arrays (`Uint8Array`, `Int8Array`, `Float32Array`, etc.) - `Map`, `Set`, `Array` - Plain objects @@ -434,7 +434,7 @@ For the full query API, schema migrations, transactions, and the Drizzle ORM, se ## 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/sitemap/mod.ts b/website/src/sitemap/mod.ts index 1eecc53763..8a07307074 100644 --- a/website/src/sitemap/mod.ts +++ b/website/src/sitemap/mod.ts @@ -324,10 +324,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", From d6ec574f902ee5c6234fb0a9ce185ef758df0aae Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 12 Jun 2026 16:24:23 -0700 Subject: [PATCH 06/10] [SLOP(gpt-5)] fix(rivetkit): normalize protocol args source-side --- CLAUDE.md | 1 + .../rivetkit-core/src/registry/http.rs | 18 ++++---- .../rivetkit-core/src/registry/inspector.rs | 27 +++++++++++- .../rivetkit-core/src/registry/mod.rs | 1 + rivetkit-rust/packages/rivetkit/src/action.rs | 42 +++++++++++++------ .../packages/rivetkit/src/context.rs | 5 ++- .../packages/rivetkit/src/typed_client.rs | 27 +++++++++++- .../packages/rivetkit/tests/client.rs | 4 +- .../packages/rivetkit/src/registry/native.ts | 42 +++++++++++++++---- .../content/docs/actors/quickstart/rust.mdx | 24 +++++------ website/src/content/docs/actors/state.mdx | 39 ++++++++++------- 11 files changed, 168 insertions(+), 62 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 71fe123a19..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 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/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/quickstart/rust.mdx b/website/src/content/docs/actors/quickstart/rust.mdx index 2958a6b912..ef81451a0e 100644 --- a/website/src/content/docs/actors/quickstart/rust.mdx +++ b/website/src/content/docs/actors/quickstart/rust.mdx @@ -198,16 +198,17 @@ const client = createClient("http://localhost:6420"); const counter = client.counter.getOrCreate(["my-counter"]); -const count = await counter.increment(3); +const counterConnection = counter.connect(); +counterConnection.on("newCount", (event) => { + console.log("Event count:", event.count); +}); + +const count = await counterConnection.increment(3); console.log("New count:", count); -await counter.connect().increment(1); +await counterConnection.increment(1); ``` - -Events emitted by a Rust actor with `ctx.emit(...)` are broadcast as a single serialized struct value. The TypeScript and React clients deliver event arguments positionally, so consuming a Rust struct event from JavaScript is not supported yet. Call actions across languages, and subscribe to events from Rust clients. - - See the [JavaScript client documentation](/docs/clients/javascript) for more information. @@ -229,10 +230,13 @@ function Counter() { }); const increment = async () => { - const next = await counter.connection?.increment(1); - if (typeof next === "number") setCount(next); + await counter.connection?.increment(1); }; + counter.useEvent("newCount", (event) => { + setCount(event.count); + }); + return (

Count: {count}

@@ -242,10 +246,6 @@ function Counter() { } ``` - -Events emitted by a Rust actor with `ctx.emit(...)` are broadcast as a single serialized struct value. The TypeScript and React clients deliver event arguments positionally, so `useEvent` cannot consume a Rust struct event yet. This example reads the action return value instead. - - See the [React documentation](/docs/clients/react) for more information. diff --git a/website/src/content/docs/actors/state.mdx b/website/src/content/docs/actors/state.mdx index 98661fa864..dcdd808127 100644 --- a/website/src/content/docs/actors/state.mdx +++ b/website/src/content/docs/actors/state.mdx @@ -4,7 +4,7 @@ description: "Actors store state in memory for instant reads and writes. State c skill: true --- -## Durable vs Ephemeral +## Types of State There are three ways to store data in an actor, depending on what it looks like and whether it needs to survive restarts. @@ -150,19 +150,21 @@ function createEventEmitter(): EventEmitter { import { actor } from "rivetkit"; import { Pool } from "pg"; +// 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 }, - // Open a connection and load initial data on every start + // Load this actor's row from the shared pool on each 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 { profile: 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]]); } } }); @@ -340,24 +342,26 @@ const room = actor({ ### Loading from external sources -`createVars` can be `async`, so open a connection and load initial data on each start. The connection lives only in memory: +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"; +// 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: "" }, createVars: async (c) => { - const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const { rows } = await pool.query("SELECT * FROM users WHERE id = $1", [c.key[0]]); - return { pool, user: rows[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]]); } } }); @@ -367,15 +371,18 @@ When the actor owns its data, prefer [durable state](#durable-state) or [SQLite] ### Cleanup -`vars` is dropped when the actor stops, but external resources are not closed for you. Release them in `onSleep` and `onDestroy`: +`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 profile = actor({ - createVars: () => ({ pool: new Pool() }), +const poller = actor({ + state: { ticks: 0 }, + + // Per-actor timer started on each wake + createVars: (c) => ({ timer: setInterval(() => c.state.ticks++, 5000) }), - // Close the connection before the actor sleeps or is destroyed - onSleep: (c) => c.vars.pool.end(), - onDestroy: (c) => c.vars.pool.end(), + // Clear it before the actor sleeps or is destroyed + onSleep: (c) => clearInterval(c.vars.timer), + onDestroy: (c) => clearInterval(c.vars.timer), actions: { /* ... */ } }); From f47b16078ebd11a6ee01d4bf5e17f8877ab1aca6 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 12 Jun 2026 17:15:26 -0700 Subject: [PATCH 07/10] [SLOP(claude-opus-4-8)] docs(examples): split effect example into hello-world-effect and chat-room-effect --- examples/chat-room-effect/.gitignore | 2 + examples/chat-room-effect/README.md | 46 ++++ .../{effect => chat-room-effect}/package.json | 7 +- .../src/actors/chat-room/api.ts | 0 .../src/actors/chat-room/live.ts | 0 .../src/actors/mod.ts | 0 .../src/actors/moderator/api.ts | 0 .../src/actors/moderator/live.ts | 0 .../src/client-raw.ts | 0 .../src/client.ts | 0 .../src/logger.ts | 0 .../{effect => chat-room-effect}/src/main.ts | 0 .../chat-room-effect/tests/chat-room.test.ts | 85 +++++++ .../tsconfig.json | 2 +- .../{effect => chat-room-effect}/turbo.json | 0 examples/chat-room-effect/vitest.config.ts | 7 + examples/hello-world-effect/.gitignore | 2 + examples/hello-world-effect/README.md | 42 ++++ examples/hello-world-effect/package.json | 32 +++ .../src/actors/counter/api.ts | 26 +++ .../src/actors/counter/live.ts | 39 ++++ examples/hello-world-effect/src/actors/mod.ts | 1 + examples/hello-world-effect/src/client.ts | 26 +++ examples/hello-world-effect/src/logger.ts | 8 + examples/hello-world-effect/src/main.ts | 30 +++ .../hello-world-effect/tests/counter.test.ts | 37 +++ examples/hello-world-effect/tsconfig.json | 15 ++ examples/hello-world-effect/turbo.json | 4 + examples/hello-world-effect/vitest.config.ts | 7 + pnpm-lock.yaml | 135 +++++++---- .../content/docs/actors/quickstart/effect.mdx | 212 ++++++++++++++++++ .../content/docs/actors/quickstart/index.mdx | 10 +- .../content/docs/actors/quickstart/rust.mdx | 4 +- website/src/sitemap/mod.ts | 16 +- 34 files changed, 740 insertions(+), 55 deletions(-) create mode 100644 examples/chat-room-effect/.gitignore create mode 100644 examples/chat-room-effect/README.md rename examples/{effect => chat-room-effect}/package.json (83%) rename examples/{effect => chat-room-effect}/src/actors/chat-room/api.ts (100%) rename examples/{effect => chat-room-effect}/src/actors/chat-room/live.ts (100%) rename examples/{effect => chat-room-effect}/src/actors/mod.ts (100%) rename examples/{effect => chat-room-effect}/src/actors/moderator/api.ts (100%) rename examples/{effect => chat-room-effect}/src/actors/moderator/live.ts (100%) rename examples/{effect => chat-room-effect}/src/client-raw.ts (100%) rename examples/{effect => chat-room-effect}/src/client.ts (100%) rename examples/{effect => chat-room-effect}/src/logger.ts (100%) rename examples/{effect => chat-room-effect}/src/main.ts (100%) create mode 100644 examples/chat-room-effect/tests/chat-room.test.ts rename examples/{effect => chat-room-effect}/tsconfig.json (88%) rename examples/{effect => chat-room-effect}/turbo.json (100%) create mode 100644 examples/chat-room-effect/vitest.config.ts create mode 100644 examples/hello-world-effect/.gitignore create mode 100644 examples/hello-world-effect/README.md create mode 100644 examples/hello-world-effect/package.json create mode 100644 examples/hello-world-effect/src/actors/counter/api.ts create mode 100644 examples/hello-world-effect/src/actors/counter/live.ts create mode 100644 examples/hello-world-effect/src/actors/mod.ts create mode 100644 examples/hello-world-effect/src/client.ts create mode 100644 examples/hello-world-effect/src/logger.ts create mode 100644 examples/hello-world-effect/src/main.ts create mode 100644 examples/hello-world-effect/tests/counter.test.ts create mode 100644 examples/hello-world-effect/tsconfig.json create mode 100644 examples/hello-world-effect/turbo.json create mode 100644 examples/hello-world-effect/vitest.config.ts create mode 100644 website/src/content/docs/actors/quickstart/effect.mdx 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/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` + -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. See the full API reference on [docs.rs/rivetkit](https://docs.rs/rivetkit). +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 diff --git a/website/src/sitemap/mod.ts b/website/src/sitemap/mod.ts index 8a07307074..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", }, ], }, @@ -406,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", @@ -418,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", From a502bbfe4d3b8fa4c89f69c3330c5ade49bd4fc9 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 12 Jun 2026 20:05:19 -0700 Subject: [PATCH 08/10] [SLOP(claude-opus-4-8)] refactor(rivetkit): deprecate actor kv api across core and typescript --- .../rivetkit-core/src/actor/context.rs | 29 +++++++++++++++++++ .../packages/rivetkit-core/src/actor/task.rs | 4 +-- .../rivetkit-core/src/inspector/auth.rs | 6 ++-- .../packages/rivetkit/src/context.rs | 4 +++ .../packages/rivetkit/src/actor/config.ts | 8 +++++ website/src/sitemap/mod.ts | 5 ---- 6 files changed, 46 insertions(+), 10 deletions(-) diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs index a6fd28139d..89e245de11 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/context.rs @@ -323,22 +323,37 @@ impl ActorContext { ctx } + #[deprecated( + note = "Actor KV is deprecated. Use embedded SQLite (`sql()`) or actor state instead." + )] pub async fn kv_batch_get(&self, keys: &[&[u8]]) -> Result>>> { self.0.kv.batch_get(keys).await } + #[deprecated( + note = "Actor KV is deprecated. Use embedded SQLite (`sql()`) or actor state instead." + )] pub async fn kv_batch_put(&self, entries: &[(&[u8], &[u8])]) -> Result<()> { self.0.kv.batch_put(entries).await } + #[deprecated( + note = "Actor KV is deprecated. Use embedded SQLite (`sql()`) or actor state instead." + )] pub async fn kv_batch_delete(&self, keys: &[&[u8]]) -> Result<()> { self.0.kv.batch_delete(keys).await } + #[deprecated( + note = "Actor KV is deprecated. Use embedded SQLite (`sql()`) or actor state instead." + )] pub async fn kv_delete_range(&self, start: &[u8], end: &[u8]) -> Result<()> { self.0.kv.delete_range(start, end).await } + #[deprecated( + note = "Actor KV is deprecated. Use embedded SQLite (`sql()`) or actor state instead." + )] pub async fn kv_list_prefix( &self, prefix: &[u8], @@ -347,6 +362,9 @@ impl ActorContext { self.0.kv.list_prefix(prefix, opts).await } + #[deprecated( + note = "Actor KV is deprecated. Use embedded SQLite (`sql()`) or actor state instead." + )] pub async fn kv_list_range( &self, start: &[u8], @@ -356,10 +374,21 @@ impl ActorContext { self.0.kv.list_range(start, end, opts).await } + #[deprecated( + note = "Actor KV is deprecated. Use embedded SQLite (`sql()`) or actor state instead." + )] pub fn kv(&self) -> &Kv { &self.0.kv } + /// Internal accessor for the actor KV store. Core uses KV as the backing + /// store for the inspector token and actor startup persistence. Unlike the + /// public `kv()` accessor, this is not deprecated because the storage + /// machinery itself is not going away, only the public actor-facing API. + pub(crate) fn kv_internal(&self) -> &Kv { + &self.0.kv + } + pub fn sql(&self) -> &SqliteDb { &self.0.sql } diff --git a/rivetkit-rust/packages/rivetkit-core/src/actor/task.rs b/rivetkit-rust/packages/rivetkit-core/src/actor/task.rs index 3cb939cf42..c87602edc5 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/actor/task.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/actor/task.rs @@ -1260,7 +1260,7 @@ impl ActorTask { let mut values = self .ctx - .kv() + .kv_internal() .batch_get(&[PERSIST_DATA_KEY, LAST_PUSHED_ALARM_KEY]) .await .context("load persisted actor startup data")? @@ -1298,7 +1298,7 @@ impl ActorTask { } self.ctx - .kv() + .kv_internal() .get(key) .await .context("load persisted actor startup key") diff --git a/rivetkit-rust/packages/rivetkit-core/src/inspector/auth.rs b/rivetkit-rust/packages/rivetkit-core/src/inspector/auth.rs index 3fd170f5b8..e147221660 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/inspector/auth.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/inspector/auth.rs @@ -58,7 +58,7 @@ impl InspectorAuth { } let stored_token = ctx - .kv() + .kv_internal() .get(&INSPECTOR_TOKEN_KEY) .await .ok() @@ -91,7 +91,7 @@ pub(crate) async fn init_inspector_token_with_preload( match preloaded_kv.and_then(|preloaded| preloaded.key_entry(&INSPECTOR_TOKEN_KEY)) { Some(existing) => existing, None => ctx - .kv() + .kv_internal() .get(&INSPECTOR_TOKEN_KEY) .await .context("load inspector token")?, @@ -101,7 +101,7 @@ pub(crate) async fn init_inspector_token_with_preload( } let token = generate_inspector_token(); - ctx.kv() + ctx.kv_internal() .put(&INSPECTOR_TOKEN_KEY, token.as_bytes()) .await .context("persist inspector token")?; diff --git a/rivetkit-rust/packages/rivetkit/src/context.rs b/rivetkit-rust/packages/rivetkit/src/context.rs index 25e4f0e514..8775ad2c98 100644 --- a/rivetkit-rust/packages/rivetkit/src/context.rs +++ b/rivetkit-rust/packages/rivetkit/src/context.rs @@ -211,6 +211,10 @@ impl Ctx
{ self.inner.region() } + #[deprecated( + note = "Actor KV is deprecated. Use embedded SQLite (`sql()`) or actor state instead." + )] + #[allow(deprecated)] pub fn kv(&self) -> &Kv { self.inner.kv() } diff --git a/rivetkit-typescript/packages/rivetkit/src/actor/config.ts b/rivetkit-typescript/packages/rivetkit/src/actor/config.ts index ea94448566..2d16893efa 100644 --- a/rivetkit-typescript/packages/rivetkit/src/actor/config.ts +++ b/rivetkit-typescript/packages/rivetkit/src/actor/config.ts @@ -62,6 +62,10 @@ type ActorKvListOptions< type ActorClientFor = T extends Registry ? Client : T; +/** + * @deprecated Actor KV is deprecated. Use embedded SQLite (`c.db` / `c.sql`) + * or actor state instead. + */ export interface ActorKv { get( key: Uint8Array | string, @@ -305,6 +309,10 @@ export interface ActorContext< [RAW_STATE_SYMBOL](): TState; state: TState; vars: TVars; + /** + * @deprecated Actor KV is deprecated. Use embedded SQLite (`db` / `sql`) + * or actor state instead. + */ readonly kv: ActorKv; readonly db: InferDatabaseClient; readonly schedule: ActorSchedule; diff --git a/website/src/sitemap/mod.ts b/website/src/sitemap/mod.ts index 5fac6910b7..2df20006c9 100644 --- a/website/src/sitemap/mod.ts +++ b/website/src/sitemap/mod.ts @@ -299,11 +299,6 @@ export const sitemap = [ // icon: faSitemap, collapsible: true, pages: [ - { - title: "Low-Level KV Storage", - href: "/docs/actors/kv", - badge: "Deprecated", - }, { title: "SQLite + Drizzle", href: "/docs/actors/sqlite-drizzle", From caf540b71886bc9ad51e2f27a4e7a20ce153b23a Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sat, 13 Jun 2026 12:16:20 -0700 Subject: [PATCH 09/10] [SLOP(claude-opus-4-8[1m]-medium)] docs(website): consolidate landing pages, deploy quickstarts, and docs restructuring --- frontend/packages/shared-data/src/deploy.ts | 40 +- website/redirects.mjs | 17 + website/src/components/docs/DocsLanding.tsx | 141 +++ website/src/components/docs/docsLandings.ts | 73 ++ .../marketing/components/PlatformIcons.tsx | 8 +- .../marketing/solutions/AgentOSPage.tsx | 2 +- .../src/content/docs/actors/crash-course.mdx | 863 +++++++++++++++++ website/src/content/docs/actors/index.mdx | 886 +----------------- .../docs/actors/quickstart/backend.mdx | 6 - .../content/docs/actors/quickstart/effect.mdx | 56 ++ .../content/docs/actors/quickstart/index.mdx | 74 -- .../docs/actors/quickstart/next-js.mdx | 2 +- .../content/docs/actors/sqlite-drizzle.mdx | 4 +- .../content/docs/agent-os/crash-course.mdx | 590 ++++++++++++ website/src/content/docs/agent-os/index.mdx | 604 +----------- .../src/content/docs/connect/cloudflare.mdx | 154 --- website/src/content/docs/connect/index.mdx | 23 - .../content/docs/connect/rivet-compute.mdx | 163 ---- website/src/content/docs/connect/supabase.mdx | 141 --- .../docs/{connect => deploy}/aws-ecs.mdx | 0 .../docs/{connect => deploy}/aws-lambda.mdx | 0 .../src/content/docs/deploy/cloudflare.mdx | 68 ++ .../docs/{connect => deploy}/custom.mdx | 0 .../docs/{connect => deploy}/freestyle.mdx | 0 .../{connect => deploy}/gcp-cloud-run.mdx | 0 .../docs/{connect => deploy}/hetzner.mdx | 0 website/src/content/docs/deploy/index.mdx | 9 + .../docs/{connect => deploy}/kubernetes.mdx | 0 .../docs/{connect => deploy}/railway.mdx | 0 website/src/content/docs/deploy/supabase.mdx | 63 ++ .../docs/{connect => deploy}/vercel.mdx | 84 +- .../{connect => deploy}/vm-and-bare-metal.mdx | 0 .../docs/general/pool-configuration.mdx | 8 +- .../content/docs/general/runtime-modes.mdx | 2 + website/src/content/docs/quickstart/index.mdx | 12 +- .../src/content/docs/self-hosting/index.mdx | 2 +- .../src/content/docs/self-hosting/render.mdx | 2 +- website/src/metadata/skills.ts | 2 +- website/src/pages/docs/[...slug].astro | 91 +- website/src/sitemap/mod.ts | 49 +- 40 files changed, 2056 insertions(+), 2183 deletions(-) create mode 100644 website/src/components/docs/DocsLanding.tsx create mode 100644 website/src/components/docs/docsLandings.ts create mode 100644 website/src/content/docs/actors/crash-course.mdx delete mode 100644 website/src/content/docs/actors/quickstart/index.mdx create mode 100644 website/src/content/docs/agent-os/crash-course.mdx delete mode 100644 website/src/content/docs/connect/cloudflare.mdx delete mode 100644 website/src/content/docs/connect/index.mdx delete mode 100644 website/src/content/docs/connect/rivet-compute.mdx delete mode 100644 website/src/content/docs/connect/supabase.mdx rename website/src/content/docs/{connect => deploy}/aws-ecs.mdx (100%) rename website/src/content/docs/{connect => deploy}/aws-lambda.mdx (100%) create mode 100644 website/src/content/docs/deploy/cloudflare.mdx rename website/src/content/docs/{connect => deploy}/custom.mdx (100%) rename website/src/content/docs/{connect => deploy}/freestyle.mdx (100%) rename website/src/content/docs/{connect => deploy}/gcp-cloud-run.mdx (100%) rename website/src/content/docs/{connect => deploy}/hetzner.mdx (100%) create mode 100644 website/src/content/docs/deploy/index.mdx rename website/src/content/docs/{connect => deploy}/kubernetes.mdx (100%) rename website/src/content/docs/{connect => deploy}/railway.mdx (100%) create mode 100644 website/src/content/docs/deploy/supabase.mdx rename website/src/content/docs/{connect => deploy}/vercel.mdx (66%) rename website/src/content/docs/{connect => deploy}/vm-and-bare-metal.mdx (100%) diff --git a/frontend/packages/shared-data/src/deploy.ts b/frontend/packages/shared-data/src/deploy.ts index e44ba180a3..afd9adfc74 100644 --- a/frontend/packages/shared-data/src/deploy.ts +++ b/frontend/packages/shared-data/src/deploy.ts @@ -1,7 +1,6 @@ import { faAws, faCloudflare, - faFunction, faGoogleCloud, faHetznerH, faKubernetes, @@ -12,6 +11,21 @@ import { faVercel, } from "@rivet-gg/icons"; +// Supabase's official monotone logo. Font Awesome has no Supabase brand icon, +// so this is a Font Awesome compatible icon definition built from the real +// brand SVG (https://simpleicons.org/?q=supabase). Renders in currentColor. +export const faSupabase = { + prefix: "fak", + iconName: "supabase", + icon: [ + 24, + 24, + [], + "", + "M11.9 1.036c-.015-.986-1.26-1.41-1.874-.637L.764 12.05C-.33 13.427.65 15.455 2.409 15.455h9.579l.113 7.51c.014.985 1.259 1.408 1.873.636l9.262-11.653c1.093-1.375.113-3.403-1.645-3.403h-9.642z", + ], +} as any; + export interface DeployOption { displayName: string; name: string; @@ -28,7 +42,7 @@ export const deployOptions = [ { displayName: "Rivet Compute", name: "rivet" as const, - href: "/docs/connect/rivet-compute", + href: "/docs/deploy/rivet-compute", description: "Deploy to Rivet's managed compute platform", icon: faRivet as any, @@ -37,7 +51,7 @@ export const deployOptions = [ { displayName: "Vercel", name: "vercel" as const, - href: "/docs/connect/vercel", + href: "/docs/deploy/vercel", description: "Deploy Next.js + RivetKit apps to Vercel's edge network", icon: faVercel as any, }, @@ -45,7 +59,7 @@ export const deployOptions = [ displayName: "Cloudflare Workers", shortTitle: "Cloudflare", name: "cloudflare-workers" as const, - href: "/docs/connect/cloudflare", + href: "/docs/deploy/cloudflare", description: "Run RivetKit on Cloudflare Workers with the WebAssembly runtime", icon: faCloudflare as any, @@ -55,23 +69,23 @@ export const deployOptions = [ displayName: "Supabase Functions", shortTitle: "Supabase", name: "supabase-functions" as const, - href: "/docs/connect/supabase", + href: "/docs/deploy/supabase", description: "Run RivetKit on Supabase Edge Functions with the WebAssembly runtime", - icon: faFunction as any, + icon: faSupabase, specializedPlatform: true, }, { displayName: "Railway", name: "railway" as const, - href: "/docs/connect/railway", + href: "/docs/deploy/railway", description: "Deploy containers to Railway's managed infrastructure", icon: faRailway as any, }, { displayName: "Kubernetes", name: "kubernetes" as const, - href: "/docs/connect/kubernetes", + href: "/docs/deploy/kubernetes", description: "Deploy to any Kubernetes cluster with container images", icon: faKubernetes as any, }, @@ -79,7 +93,7 @@ export const deployOptions = [ displayName: "AWS ECS", shortTitle: "AWS", name: "aws-ecs" as const, - href: "/docs/connect/aws-ecs", + href: "/docs/deploy/aws-ecs", description: "Run containerized workloads on Amazon Elastic Container Service", icon: faAws as any, @@ -88,14 +102,14 @@ export const deployOptions = [ displayName: "Google Cloud Run", shortTitle: "GCP", name: "gcp-cloud-run" as const, - href: "/docs/connect/gcp-cloud-run", + href: "/docs/deploy/gcp-cloud-run", description: "Deploy containers to Google Cloud Run for auto-scaling", icon: faGoogleCloud, }, { displayName: "Hetzner", name: "hetzner" as const, - href: "/docs/connect/hetzner", + href: "/docs/deploy/hetzner", description: "Deploy to Hetzner's cost-effective cloud infrastructure", icon: faHetznerH as any, }, @@ -103,7 +117,7 @@ export const deployOptions = [ displayName: "VM & Bare Metal", name: "custom" as const, shortTitle: "VM", - href: "/docs/connect/vm-and-bare-metal", + href: "/docs/deploy/vm-and-bare-metal", description: "Run on virtual machines or bare metal servers with full control", icon: faServer as any, @@ -111,7 +125,7 @@ export const deployOptions = [ { displayName: "Custom Platform", name: "custom-platform" as const, - href: "/docs/connect/custom", + href: "/docs/deploy/custom", description: "Integrate RivetKit with any other hosting platform of your choice", icon: faRocket as any, diff --git a/website/redirects.mjs b/website/redirects.mjs index 3137f0ed40..ee5f60200c 100644 --- a/website/redirects.mjs +++ b/website/redirects.mjs @@ -26,6 +26,23 @@ export const redirects = { '/docs/platforms/next-js': '/docs/clients/javascript/', // Registry configuration moved '/docs/connect/registry-configuration': '/docs/general/registry-configuration/', + // Quickstart index merged into the Actors introduction + '/docs/actors/quickstart': '/docs/actors/', + // Connect tab renamed to Deploy + '/docs/connect': '/docs/deploy/', + '/docs/connect/aws-ecs': '/docs/deploy/aws-ecs/', + '/docs/connect/aws-lambda': '/docs/deploy/aws-lambda/', + '/docs/connect/cloudflare': '/docs/deploy/cloudflare/', + '/docs/connect/custom': '/docs/deploy/custom/', + '/docs/connect/freestyle': '/docs/deploy/freestyle/', + '/docs/connect/gcp-cloud-run': '/docs/deploy/gcp-cloud-run/', + '/docs/connect/hetzner': '/docs/deploy/hetzner/', + '/docs/connect/kubernetes': '/docs/deploy/kubernetes/', + '/docs/connect/railway': '/docs/deploy/railway/', + '/docs/connect/rivet-compute': '/docs/deploy/rivet-compute/', + '/docs/connect/supabase': '/docs/deploy/supabase/', + '/docs/connect/vercel': '/docs/deploy/vercel/', + '/docs/connect/vm-and-bare-metal': '/docs/deploy/vm-and-bare-metal/', // Cloud docs removed - redirect to relevant sections '/docs/cloud': '/docs/self-hosting/', '/docs/cloud/api/actors/create': '/docs/actors/', diff --git a/website/src/components/docs/DocsLanding.tsx b/website/src/components/docs/DocsLanding.tsx new file mode 100644 index 0000000000..9ba173396e --- /dev/null +++ b/website/src/components/docs/DocsLanding.tsx @@ -0,0 +1,141 @@ +import { Icon } from "@rivet-gg/icons"; +import { AnimatedAgentOSLogo } from "@/components/marketing/solutions/AgentOSPage"; +import actorsLogo from "@/images/products/actors-logo.svg"; + +export interface DocsLandingItem { + title: string; + href: string; + icon: any; + description?: string; + badge?: string; +} + +export interface DocsLandingSection { + title: string; + items: DocsLandingItem[]; +} + +export interface DocsLandingData { + title: string; + subtitle?: string; + // Optional product logo shown above the title in the hero. "agentos" renders + // the animated agentOS logo reused from the marketing page; "actors" renders + // the static actors logo. + logo?: "agentos" | "actors"; + sections: DocsLandingSection[]; +} + +function HeroTitle({ + title, + logo, +}: { + title: string; + logo?: "agentos" | "actors"; +}) { + if (logo === "agentos") { + // The animated agentOS logo is the wordmark, so it stands in for the title. + // The source wordmark is black, which reads correctly on the light + // porcelain docs background. + return ( +
+ +
+ ); + } + if (logo === "actors") { + return ( +
+ {/* The actors mark is a solid-white SVG, so darken it to ink to read + on the light porcelain background. */} + +

+ {title} +

+
+ ); + } + return ( +

+ {title} +

+ ); +} + +// Faint grid backdrop for the card illustration area. Masked with a radial fade +// so the grid is strongest behind the icon and dissolves toward the edges. Uses +// ink-tinted hairlines so it reads on the light porcelain background. +const gridStyle = { + backgroundImage: + "linear-gradient(to right, rgba(20,22,20,0.05) 1px, transparent 1px), linear-gradient(to bottom, rgba(20,22,20,0.05) 1px, transparent 1px)", + backgroundSize: "24px 24px", + maskImage: "radial-gradient(ellipse 60% 60% at 50% 50%, black 20%, transparent 80%)", + WebkitMaskImage: + "radial-gradient(ellipse 60% 60% at 50% 50%, black 20%, transparent 80%)", +}; + +function LandingCard({ item }: { item: DocsLandingItem }) { + return ( +
+
+
+ +
+
+
+ {item.title} + {item.badge && ( + + {item.badge} + + )} +
+ {item.description && ( +

+ {item.description} +

+ )} +
+
+ ); +} + +export function DocsLanding({ title, subtitle, logo, sections }: DocsLandingData) { + const showHeaders = sections.length > 1; + + return ( +
+
+ + {subtitle && ( +

{subtitle}

+ )} +
+
+ {sections.map((section) => ( +
+ {showHeaders && ( +

+ {section.title} +

+ )} +
+ {section.items.map((item) => ( + + ))} +
+
+ ))} +
+
+ ); +} diff --git a/website/src/components/docs/docsLandings.ts b/website/src/components/docs/docsLandings.ts new file mode 100644 index 0000000000..3027bcc69a --- /dev/null +++ b/website/src/components/docs/docsLandings.ts @@ -0,0 +1,73 @@ +import { + faCloudflare, + faFastForward, + faLayerGroup, + faLightbulb, + faNextjs, + faNodeJs, + faReact, + faRust, + faScaleBalanced, +} from "@rivet-gg/icons"; +import { deployOptions, faSupabase } from "@rivetkit/shared-data"; +import type { DocsLandingData } from "./DocsLanding"; + +const actors: DocsLandingData = { + title: "Actors", + subtitle: + "Long-lived processes with durable state, realtime events, and built-in hibernation. Pick a stack to start building.", + logo: "actors", + sections: [ + { + title: "Get Started", + items: [ + { title: "Node.js & Bun", href: "/docs/actors/quickstart/backend", icon: faNodeJs, description: "Set up actors with Node.js, Bun, and web frameworks." }, + { title: "React", href: "/docs/actors/quickstart/react", icon: faReact, description: "Build realtime React applications backed by actors." }, + { title: "Next.js", href: "/docs/actors/quickstart/next-js", icon: faNextjs, description: "Server-rendered Next.js experiences backed by actors." }, + { title: "Rust", href: "/docs/actors/quickstart/rust", icon: faRust, badge: "Beta", description: "Native Rust with the typed rivetkit crate." }, + { title: "Effect.ts", href: "/docs/actors/quickstart/effect", icon: faLayerGroup, badge: "Beta", description: "The Effect SDK with typed Schema actions." }, + { title: "Cloudflare Workers", href: "/docs/actors/quickstart/cloudflare", icon: faCloudflare, description: "Run RivetKit on Cloudflare Workers." }, + { title: "Supabase Functions", href: "/docs/actors/quickstart/supabase", icon: faSupabase, description: "Run RivetKit on Supabase Edge Functions." }, + ], + }, + ], +}; + +const deploy: DocsLandingData = { + title: "Deploy", + subtitle: "Run RivetKit anywhere, from serverless functions to your own infrastructure.", + sections: [ + { + title: "Platforms", + items: deployOptions.map((option) => ({ + title: option.shortTitle ?? option.displayName, + href: option.href, + icon: option.icon, + badge: option.badge, + })), + }, + ], +}; + +const agentOs: DocsLandingData = { + title: "agentOS", + subtitle: + "Run coding agents inside isolated VMs with full filesystem, process, and network control.", + logo: "agentos", + sections: [ + { + title: "Get Started", + items: [ + { title: "Quickstart", href: "/docs/agent-os/quickstart", icon: faFastForward, description: "Boot a VM and run your first coding agent." }, + { title: "Crash Course", href: "/docs/agent-os/crash-course", icon: faLightbulb, description: "Learn the core agentOS concepts." }, + { title: "agentOS vs Sandbox", href: "/docs/agent-os/versus-sandbox", icon: faScaleBalanced, description: "How agentOS compares to a plain sandbox." }, + ], + }, + ], +}; + +export const docsLandings: Record = { + actors, + deploy, + "agent-os": agentOs, +}; diff --git a/website/src/components/marketing/components/PlatformIcons.tsx b/website/src/components/marketing/components/PlatformIcons.tsx index 999c873810..e35384c0d7 100644 --- a/website/src/components/marketing/components/PlatformIcons.tsx +++ b/website/src/components/marketing/components/PlatformIcons.tsx @@ -142,25 +142,25 @@ export function PlatformIcons() { tooltip: 'Railway' }, { - href: '/docs/connect/kubernetes', + href: '/docs/deploy/kubernetes', src: kubernetesLogo, alt: 'Kubernetes', tooltip: 'Kubernetes' }, { - href: '/docs/connect/aws-ecs', + href: '/docs/deploy/aws-ecs', src: awsLogo, alt: 'AWS ECS', tooltip: 'AWS ECS' }, { - href: '/docs/connect/gcp-cloud-run', + href: '/docs/deploy/gcp-cloud-run', src: gcpLogo, alt: 'GCP Cloud Run', tooltip: 'GCP Cloud Run' }, { - href: '/docs/connect/hetzner', + href: '/docs/deploy/hetzner', src: hetznerLogo, alt: 'Hetzner', tooltip: 'Hetzner' diff --git a/website/src/components/marketing/solutions/AgentOSPage.tsx b/website/src/components/marketing/solutions/AgentOSPage.tsx index 052b3f86d5..9e1a5410ef 100644 --- a/website/src/components/marketing/solutions/AgentOSPage.tsx +++ b/website/src/components/marketing/solutions/AgentOSPage.tsx @@ -55,7 +55,7 @@ interface AnimatedAgentOSLogoProps { displayedAgent?: { src: string; name: string } | null; } -const AnimatedAgentOSLogo = ({ className, displayedAgent }: AnimatedAgentOSLogoProps) => { +export const AnimatedAgentOSLogo = ({ className, displayedAgent }: AnimatedAgentOSLogoProps) => { const containerRef = useRef(null); const [isReady, setIsReady] = useState(false); const osLayerRef = useRef(null); diff --git a/website/src/content/docs/actors/crash-course.mdx b/website/src/content/docs/actors/crash-course.mdx new file mode 100644 index 0000000000..eaf153c440 --- /dev/null +++ b/website/src/content/docs/actors/crash-course.mdx @@ -0,0 +1,863 @@ +--- +title: "Crash Course" +description: "Learn the core concepts of Rivet Actors: state, actions, realtime events, and clients." +skill: false +--- + +{/* SKILL_OVERVIEW_START */} + +## Features + +- **Long-Lived, Stateful Compute**: Each unit of compute is like a tiny server that remembers things between requests – no need to re-fetch data from a database or worry about timeouts. Like AWS Lambda, but with memory and no timeouts. +- **Blazing-Fast Reads & Writes**: State is stored on the same machine as your compute, so reads and writes are ultra-fast. No database round trips, no latency spikes. State is persisted to Rivet for long term storage, so it survives server restarts. +- **Realtime**: Update state and broadcast changes in realtime with WebSockets. No external pub/sub systems, no polling – just built-in low-latency events. +- **Infinitely Scalable**: Automatically scale from zero to millions of concurrent actors. Pay only for what you use with instant scaling and no cold starts. +- **Fault Tolerant**: Built-in error handling and recovery. Actors automatically restart on failure while preserving state integrity and continuing operations. + +## When to Use Rivet Actors + +- **AI agents & sandboxes**: multi-step toolchains, conversation memory, sandbox orchestration. +- **Multiplayer or collaborative apps**: CRDT docs, shared cursors, realtime dashboards, chat. +- **Workflow automation**: background jobs, cron, rate limiters, durable queues, backpressure control. +- **Data-intensive backends**: geo-distributed or per-tenant databases, in-memory caches, sharded SQL. +- **Networking workloads**: WebSocket servers, custom protocols, local-first sync, edge fanout. + +## Minimal Project + +### Backend + +**index.ts** + +```ts +import { actor, event, setup } from "rivetkit"; + +const counter = actor({ + state: { count: 0 }, + events: { + count: event(), + }, + actions: { + increment: (c, amount: number) => { + c.state.count += amount; + c.broadcast("count", c.state.count); + return c.state.count; + }, + }, +}); + +export const registry = setup({ + use: { counter }, +}); + +registry.start(); +``` + +### Client Docs + +Use the client SDK that matches your app: + +- [JavaScript Client](/docs/clients/javascript) +- [React Client](/docs/clients/react) +- [Swift Client](/docs/clients/swift) + +## Actor Quick Reference + +### In-Memory State + +Persistent data that survives restarts, crashes, and deployments. State is persisted on Rivet Cloud or Rivet self-hosted, so it survives restarts if the current process crashes or exits. + + + +```ts +import { actor } from "rivetkit"; + +const counter = actor({ +state: { count: 0 }, +actions: { +increment: (c) => c.state.count += 1, +}, +}); + +```` + + +```ts +import { actor } from "rivetkit"; + +interface CounterState { + count: number; +} + +const counter = actor({ + createState: (c, input: { start?: number }): CounterState => ({ + count: input.start ?? 0, + }), + actions: { + increment: (c) => c.state.count += 1, + }, +}); +```` + + + + +[Documentation](/docs/actors/state) + +### Keys + +Keys uniquely identify actor instances. Use compound keys (arrays) for hierarchical addressing: + +```ts +import { actor, setup } from "rivetkit"; +import { createClient } from "rivetkit/client"; + +const chatRoom = actor({ + state: { messages: [] as string[] }, + actions: { + getRoomInfo: (c) => ({ org: c.key[0], room: c.key[1] }), + }, +}); + +const registry = setup({ use: { chatRoom } }); +const client = createClient("http://localhost:6420"); + +// Compound key: [org, room] +client.chatRoom.getOrCreate(["org-acme", "general"]); + +// Access key inside actor via c.key +``` + +Don't build keys with string interpolation like `"org:${userId}"` when `userId` contains user data. Use arrays instead to prevent key injection attacks. + +[Documentation](/docs/actors/keys) + +### Input + +Pass initialization data when creating actors. Input is only available in `createState` and `onCreate`, so store it in state if you need it later. + +```ts +import { actor, setup } from "rivetkit"; +import { createClient } from "rivetkit/client"; + +const game = actor({ + state: { mode: "" }, + createState: (c, input: { mode: string }) => ({ + mode: input.mode, // Store input in state for later access + }), + actions: { + getMode: (c) => c.state.mode, + }, +}); + +const registry = setup({ use: { game } }); +const client = createClient("http://localhost:6420"); + +// Client usage +const gameHandle = client.game.getOrCreate(["game-1"], { + createWithInput: { mode: "ranked" }, +}); +``` + +[Documentation](/docs/actors/input) + +### Temporary Variables + +Temporary data that doesn't survive restarts. Use for non-serializable objects (event emitters, connections, etc). + + + +```ts +import { actor } from "rivetkit"; + +const counter = actor({ +state: { count: 0 }, +vars: { lastAccess: 0 }, +actions: { +increment: (c) => { +c.vars.lastAccess = Date.now(); +return c.state.count += 1; +}, +}, +}); + +```` + + +```ts +import { actor } from "rivetkit"; + +const counter = actor({ + state: { count: 0 }, + createVars: () => ({ + emitter: new EventTarget(), + }), + actions: { + increment: (c) => { + c.vars.emitter.dispatchEvent(new Event("change")); + return c.state.count += 1; + }, + }, +}); +```` + + + + +[Documentation](/docs/actors/state) + +### Actions + +Actions are the primary way clients and other actors communicate with an actor. + +```ts +import { actor } from "rivetkit"; + +const counter = actor({ + state: { count: 0 }, + actions: { + increment: (c, amount: number) => (c.state.count += amount), + getCount: (c) => c.state.count, + }, +}); +``` + +[Documentation](/docs/actors/actions) + +### Events & Broadcasts + +Events enable real-time communication from actors to connected clients. + +```ts +import { actor, event } from "rivetkit"; + +const chatRoom = actor({ + state: { messages: [] as string[] }, + events: { + newMessage: event<{ text: string }>(), + }, + actions: { + sendMessage: (c, text: string) => { + // Broadcast to ALL connected clients + c.broadcast("newMessage", { text }); + }, + }, +}); +``` + +[Documentation](/docs/actors/events) + +### 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. `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. + + + +```ts +import { actor } from "rivetkit"; + +const chatRoom = actor({ +state: {}, +connState: { visitorId: 0 }, +onConnect: (c, conn) => { +conn.state.visitorId = Math.random(); +}, +actions: { +whoAmI: (c) => c.conn.state.visitorId, +}, +}); + +```` + + +```ts +import { actor } from "rivetkit"; + +const chatRoom = actor({ + state: {}, + // params passed from client + createConnState: (c, params: { userId: string }) => ({ + userId: params.userId, + }), + actions: { + // Access current connection's state and params + whoAmI: (c) => ({ + state: c.conn.state, + params: c.conn.params, + }), + // Iterate all connections with c.conns + notifyOthers: (c, text: string) => { + for (const conn of c.conns.values()) { + if (conn !== c.conn) conn.send("notification", { text }); + } + }, + }, +}); +```` + + + + +[Documentation](/docs/actors/connections) + +### Queues + +Use queues to process durable messages in order inside a `run` loop. + +```ts +import { actor, queue } from "rivetkit"; + +const counter = actor({ + state: { value: 0 }, + queues: { + increment: queue<{ amount: number }>(), + }, + run: async (c) => { + for await (const message of c.queue.iter()) { + c.state.value += message.body.amount; + } + }, +}); +``` + +[Documentation](/docs/actors/queues) + +### Workflows + +Use workflows when your `run` logic needs durable, replayable multi-step execution. + +```ts +import { actor, queue } from "rivetkit"; +import { workflow } from "rivetkit/workflow"; + +const worker = actor({ + state: { processed: 0 }, + queues: { + tasks: queue<{ url: string }>(), + }, + run: workflow(async (ctx) => { + await ctx.loop("task-loop", async (loopCtx) => { + const message = await loopCtx.queue.next("wait-task"); + + await loopCtx.step("process-task", async () => { + await processTask(message.body.url); + loopCtx.state.processed += 1; + }); + }); + }), +}); + +async function processTask(url: string): Promise { + const res = await fetch(url, { method: "POST" }); + if (!res.ok) throw new Error(`Task failed: ${res.status}`); +} +``` + +[Documentation](/docs/actors/workflows) + +### Actor-to-Actor Communication + +Actors can call other actors using `c.client()`. + +```ts +import { actor, setup } from "rivetkit"; + +const inventory = actor({ + state: { stock: 100 }, + actions: { + reserve: (c, amount: number) => { + c.state.stock -= amount; + }, + }, +}); + +const order = actor({ + state: {}, + actions: { + process: async (c) => { + const client = c.client(); + await client.inventory.getOrCreate(["main"]).reserve(1); + }, + }, +}); + +const registry = setup({ use: { inventory, order } }); +``` + +[Documentation](/docs/actors/communicating-between-actors) + +### Scheduling + +Schedule actions to run after a delay or at a specific time. Schedules persist across restarts, upgrades, and crashes. + +```ts +import { actor, event } from "rivetkit"; + +const reminder = actor({ + state: { message: "" }, + events: { + reminder: event<{ message: string }>(), + }, + actions: { + // Schedule action to run after delay (ms) + setReminder: (c, message: string, delayMs: number) => { + c.state.message = message; + c.schedule.after(delayMs, "sendReminder"); + }, + // Schedule action to run at specific timestamp + setReminderAt: (c, message: string, timestamp: number) => { + c.state.message = message; + c.schedule.at(timestamp, "sendReminder"); + }, + sendReminder: (c) => { + c.broadcast("reminder", { message: c.state.message }); + }, + }, +}); +``` + +[Documentation](/docs/actors/schedule) + +### Destroying Actors + +Permanently delete an actor and its state using `c.destroy()`. + +```ts +import { actor } from "rivetkit"; + +const userAccount = actor({ + state: { email: "", name: "" }, + onDestroy: (c) => { + console.log(`Account ${c.state.email} deleted`); + }, + actions: { + deleteAccount: (c) => { + c.destroy(); + }, + }, +}); +``` + +[Documentation](/docs/actors/destroy) + +### Lifecycle Hooks + +Actors support hooks for initialization, background processing, connections, networking, and state changes. Use `run` for long-lived background loops, and use `c.aborted` or `c.abortSignal` for graceful shutdown. + +```ts +import { actor, event, queue } from "rivetkit"; + +interface RoomState { + users: Record; + name?: string; +} + +interface RoomInput { + roomName: string; +} + +interface ConnState { + userId: string; + joinedAt: number; +} + +const chatRoom = actor({ + events: { + stateChanged: event(), + }, + queues: { + work: queue<{ task: string }>(), + }, + + // State & vars initialization + createState: (c, input: RoomInput): RoomState => ({ + users: {}, + name: input.roomName, + }), + createVars: () => ({ startTime: Date.now() }), + + // Actor lifecycle + onCreate: (c) => console.log("created", c.key), + onDestroy: (c) => console.log("destroyed"), + onWake: (c) => console.log("actor started"), + onSleep: (c) => console.log("actor sleeping"), + run: async (c) => { + for await (const message of c.queue.iter()) { + console.log("processing", message.body.task); + } + }, + onStateChange: (c, newState) => c.broadcast("stateChanged", newState), + + // Connection lifecycle + createConnState: (c, params): ConnState => ({ + userId: (params as { userId: string }).userId, + joinedAt: Date.now(), + }), + onBeforeConnect: (c, params) => { + /* validate auth */ + }, + onConnect: (c, conn) => console.log("connected:", conn.state.userId), + onDisconnect: (c, conn) => console.log("disconnected:", conn.state.userId), + + // Networking + onRequest: (c, req) => new Response(JSON.stringify(c.state)), + onWebSocket: (c, socket) => socket.addEventListener("message", console.log), + + // Response transformation + onBeforeActionResponse: ( + c: unknown, + name: string, + args: unknown[], + output: Out, + ): Out => output, + + actions: {}, +}); +``` + +[Documentation](/docs/actors/lifecycle) + +### Context Types + +When writing helper functions outside the actor definition, use `*ContextOf` to extract the correct context type. Helpers like `ActionContextOf`, `CreateContextOf`, `ConnContextOf`, and `ConnInitContextOf` are exported from `"rivetkit"`. Do not manually define your own context interface. Always derive it from the actor definition. + +```ts +import { actor, ActionContextOf } from "rivetkit"; + +const gameRoom = actor({ + state: { players: [] as string[], score: 0 }, + actions: { + addPlayer: (c, playerId: string) => { + validatePlayer(c, playerId); + c.state.players.push(playerId); + }, + }, +}); + +// Good: derive context type from actor definition +function validatePlayer(c: ActionContextOf, playerId: string) { + if (c.state.players.includes(playerId)) { + throw new Error("Player already in room"); + } +} + +// Bad: don't manually define context types like this +// type MyContext = { state: { players: string[] }; ... }; +``` + +[Documentation](/docs/actors/types) + +### Errors + +Use `UserError` to throw errors that are safely returned to clients. Pass `metadata` to include structured data. Other errors are converted to generic "internal error" for security. + + + +```ts +import { actor, UserError } from "rivetkit"; + +const user = actor({ +state: { 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; +}, +}, +}); + +```` + + +```ts +import { actor, setup, UserError } from "rivetkit"; +import { createClient, ActorError } from "rivetkit/client"; + +const user = actor({ + state: { 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 } }); +const client = createClient("http://localhost:6420"); + +try { + await client.user.getOrCreate([]).updateUsername("ab"); +} catch (error) { + if (error instanceof ActorError) { + console.log(error.code); // "username_too_short" + console.log(error.metadata); // { minLength: 3, actual: 2 } + } +} +```` + + + + +[Documentation](/docs/actors/errors) + +### Low-Level HTTP & WebSocket Handlers + +For custom protocols or integrating libraries that need direct access to HTTP `Request`/`Response` or WebSocket connections, use `onRequest` and `onWebSocket`. + +[HTTP Handler Documentation](/docs/actors/request-handler) · [WebSocket Handler Documentation](/docs/actors/websocket-handler) + +### Icons & Names + +Customize how actors appear in the UI with display names and icons. It's recommended to always provide a name and icon to actors in order to make them easier to distinguish in the dashboard. + +```typescript +import { actor } from "rivetkit"; + +const chatRoom = actor({ + options: { + name: "Chat Room", + icon: "💬", // or FontAwesome: "comments", "chart-line", etc. + }, + // ... +}); +``` + +[Documentation](/docs/actors/appearance) + +## Client Documentation + +Find the full client guides here: + +- [JavaScript Client](/docs/clients/javascript) +- [React Client](/docs/clients/react) +- [Swift Client](/docs/clients/swift) + +## Common Patterns + +Actors scale naturally through isolated state and message-passing. Structure your applications with these patterns: + +[Documentation](/docs/actors/design-patterns) + +### Actor Per Entity + +Create one actor per user, document, or room. Use compound keys to scope entities: + + +```ts client.ts +import { createClient } from "rivetkit/client"; +import type { registry } from "./index"; + +const client = createClient("http://localhost:6420"); + +// Single key: one actor per user +client.user.getOrCreate(["user-123"]); + +// Compound key: document scoped to an organization +client.document.getOrCreate(["org-acme", "doc-456"]); + +```` + +```ts index.ts +import { actor, setup } from "rivetkit"; + +export const user = actor({ + state: { name: "" }, + actions: {}, +}); + +export const document = actor({ + state: { content: "" }, + actions: {}, +}); + +export const registry = setup({ use: { user, document } }); + +registry.start(); +```` + + + +### Coordinator & Data Actors + +**Data actors** handle core logic (chat rooms, game sessions, user data). **Coordinator actors** track and manage collections of data actors—think of them as an index. + + +```ts index.ts +import { actor, setup } from "rivetkit"; + +// Coordinator: tracks chat rooms within an organization +export const chatRoomList = actor({ +state: { rooms: [] as string[] }, +actions: { +addRoom: async (c, name: string) => { +// Create the chat room actor +const client = c.client(); +await client.chatRoom.create([c.key[0], name]); +c.state.rooms.push(name); +}, +listRooms: (c) => c.state.rooms, +}, +}); + +// Data actor: handles a single chat room +export const chatRoom = actor({ +state: { messages: [] as string[] }, +actions: { +send: (c, msg: string) => { c.state.messages.push(msg); }, +}, +}); + +export const registry = setup({ use: { chatRoomList, chatRoom } }); + +registry.start(); + +```` + +```ts client.ts +import { createClient } from "rivetkit/client"; +import type { registry } from "./index"; + +const client = createClient("http://localhost:6420"); + +// Coordinator per org +const coordinator = client.chatRoomList.getOrCreate(["org-acme"]); +await coordinator.addRoom("general"); +await coordinator.addRoom("random"); + +// Access chat rooms created by coordinator +client.chatRoom.get(["org-acme", "general"]); +```` + + + +### Run Loop + +Use a `run` loop for continuous background work inside an actor. Process queue messages in order, run logic on intervals, stream AI responses, or coordinate long-running tasks. + +```ts +import { actor, queue, setup } from "rivetkit"; + +const counterWorker = actor({ + state: { value: 0 }, + queues: { + mutate: queue<{ delta: number }>(), + }, + run: async (c) => { + for await (const message of c.queue.iter()) { + c.state.value += message.body.delta; + } + }, + actions: { + getValue: (c) => c.state.value, + }, +}); + +const registry = setup({ use: { counterWorker } }); +``` + +### Workflow Loop + +Use this pattern for long-lived, durable workflows that initialize resources, process commands in a loop, then clean up. + +```ts +import { actor, queue, setup } from "rivetkit"; +import { Loop, workflow } from "rivetkit/workflow"; + +type WorkMessage = { amount: number }; +type ControlMessage = { type: "stop"; reason: string }; + +const worker = actor({ + state: { + phase: "idle" as "idle" | "running" | "stopped", + processed: 0, + total: 0, + stopReason: null as string | null, + }, + queues: { + work: queue(), + control: queue(), + }, + run: workflow(async (ctx) => { + await ctx.step("setup", async () => { + await fetch("https://api.example.com/workers/init", { + method: "POST", + }); + ctx.state.phase = "running"; + ctx.state.stopReason = null; + }); + + const stopReason = await ctx.loop("worker-loop", async (loopCtx) => { + const message = await loopCtx.queue.next("wait-command", { + names: ["work", "control"], + }); + + if (message.name === "work") { + await loopCtx.step("apply-work", async () => { + await fetch("https://api.example.com/workers/process", { + method: "POST", + body: JSON.stringify({ amount: message.body.amount }), + }); + loopCtx.state.processed += 1; + loopCtx.state.total += message.body.amount; + }); + return; + } + + return Loop.break((message.body as ControlMessage).reason); + }); + + await ctx.step("teardown", async () => { + await fetch("https://api.example.com/workers/shutdown", { + method: "POST", + }); + ctx.state.phase = "stopped"; + ctx.state.stopReason = stopReason; + }); + }), +}); + +const registry = setup({ use: { worker } }); +``` + +[Documentation](/docs/actors/workflows) + +### Actions vs Queues + +- **Actions** are not durable. Use them for realtime reads, ephemeral data, and low-latency communication like player input. +- **Queues** are durable. Use them to serialize mutations through the run loop, avoiding race conditions with SQLite and other local state. Callers can still wait for a response from queued work. + +### Authentication, Security, & CORS + +- Validate credentials in `onBeforeConnect` or `createConnState` and throw an error to reject unauthorized connections. +- Use `c.conn.state` to securely identify users in actions rather than trusting action parameters. +- For cross-origin access, validate the request origin in `onBeforeConnect`. + +[Authentication Documentation](/docs/actors/authentication) · [CORS Documentation](/docs/general/cors) + +### Versions & Upgrades + +When deploying new code, set a version number so Rivet can route new actors to the latest runner and optionally drain old ones. Use a build timestamp, git commit count, or CI build number as the version. It is very important to [configure versioning](/docs/actors/versions) before deploying to production. Without versioning, actors can regress by running on older runner versions, and existing actors will never be forced to migrate to new runners. They will continue running indefinitely on the old runners until they exit. + +[Documentation](/docs/actors/versions) + +### Anti-Patterns + +#### Never build a "god" actor + +Do not put all your logic in a single actor. A god actor serializes every operation through one bottleneck, kills parallelism, and makes the entire system fail as a unit. Split into focused actors per entity. + +#### Never create an actor per request + +Actors are long-lived and maintain state across requests. Creating a new actor for every incoming request throws away the core benefit of the model and wastes resources on actor creation and teardown. Use actors for persistent entities and regular functions for stateless work. + +{/* SKILL_OVERVIEW_END */} diff --git a/website/src/content/docs/actors/index.mdx b/website/src/content/docs/actors/index.mdx index b39d539e17..e9c0c1461c 100644 --- a/website/src/content/docs/actors/index.mdx +++ b/website/src/content/docs/actors/index.mdx @@ -1,887 +1,9 @@ --- -title: "Overview" +title: "Introduction" description: "Actors for long-lived processes with durable state, realtime, and hibernate when not in use." skill: false --- -import { faNodeJs, faReact, faNextjs } from "@rivet-gg/icons"; - -## Quickstart - - - - Set up actors with Node.js, Bun, and web frameworks - - - Build real-time React applications with actors - - - Build server-rendered Next.js experiences backed by actors - - - -{/* SKILL_OVERVIEW_START */} - -## Features - -- **Long-Lived, Stateful Compute**: Each unit of compute is like a tiny server that remembers things between requests – no need to re-fetch data from a database or worry about timeouts. Like AWS Lambda, but with memory and no timeouts. -- **Blazing-Fast Reads & Writes**: State is stored on the same machine as your compute, so reads and writes are ultra-fast. No database round trips, no latency spikes. State is persisted to Rivet for long term storage, so it survives server restarts. -- **Realtime**: Update state and broadcast changes in realtime with WebSockets. No external pub/sub systems, no polling – just built-in low-latency events. -- **Infinitely Scalable**: Automatically scale from zero to millions of concurrent actors. Pay only for what you use with instant scaling and no cold starts. -- **Fault Tolerant**: Built-in error handling and recovery. Actors automatically restart on failure while preserving state integrity and continuing operations. - -## When to Use Rivet Actors - -- **AI agents & sandboxes**: multi-step toolchains, conversation memory, sandbox orchestration. -- **Multiplayer or collaborative apps**: CRDT docs, shared cursors, realtime dashboards, chat. -- **Workflow automation**: background jobs, cron, rate limiters, durable queues, backpressure control. -- **Data-intensive backends**: geo-distributed or per-tenant databases, in-memory caches, sharded SQL. -- **Networking workloads**: WebSocket servers, custom protocols, local-first sync, edge fanout. - -## Minimal Project - -### Backend - -**index.ts** - -```ts -import { actor, event, setup } from "rivetkit"; - -const counter = actor({ - state: { count: 0 }, - events: { - count: event(), - }, - actions: { - increment: (c, amount: number) => { - c.state.count += amount; - c.broadcast("count", c.state.count); - return c.state.count; - }, - }, -}); - -export const registry = setup({ - use: { counter }, -}); - -registry.start(); -``` - -### Client Docs - -Use the client SDK that matches your app: - -- [JavaScript Client](/docs/clients/javascript) -- [React Client](/docs/clients/react) -- [Swift Client](/docs/clients/swift) - -## Actor Quick Reference - -### In-Memory State - -Persistent data that survives restarts, crashes, and deployments. State is persisted on Rivet Cloud or Rivet self-hosted, so it survives restarts if the current process crashes or exits. - - - -```ts -import { actor } from "rivetkit"; - -const counter = actor({ -state: { count: 0 }, -actions: { -increment: (c) => c.state.count += 1, -}, -}); - -```` - - -```ts -import { actor } from "rivetkit"; - -interface CounterState { - count: number; -} - -const counter = actor({ - createState: (c, input: { start?: number }): CounterState => ({ - count: input.start ?? 0, - }), - actions: { - increment: (c) => c.state.count += 1, - }, -}); -```` - - - - -[Documentation](/docs/actors/state) - -### Keys - -Keys uniquely identify actor instances. Use compound keys (arrays) for hierarchical addressing: - -```ts -import { actor, setup } from "rivetkit"; -import { createClient } from "rivetkit/client"; - -const chatRoom = actor({ - state: { messages: [] as string[] }, - actions: { - getRoomInfo: (c) => ({ org: c.key[0], room: c.key[1] }), - }, -}); - -const registry = setup({ use: { chatRoom } }); -const client = createClient("http://localhost:6420"); - -// Compound key: [org, room] -client.chatRoom.getOrCreate(["org-acme", "general"]); - -// Access key inside actor via c.key -``` - -Don't build keys with string interpolation like `"org:${userId}"` when `userId` contains user data. Use arrays instead to prevent key injection attacks. - -[Documentation](/docs/actors/keys) - -### Input - -Pass initialization data when creating actors. Input is only available in `createState` and `onCreate`, so store it in state if you need it later. - -```ts -import { actor, setup } from "rivetkit"; -import { createClient } from "rivetkit/client"; - -const game = actor({ - state: { mode: "" }, - createState: (c, input: { mode: string }) => ({ - mode: input.mode, // Store input in state for later access - }), - actions: { - getMode: (c) => c.state.mode, - }, -}); - -const registry = setup({ use: { game } }); -const client = createClient("http://localhost:6420"); - -// Client usage -const gameHandle = client.game.getOrCreate(["game-1"], { - createWithInput: { mode: "ranked" }, -}); -``` - -[Documentation](/docs/actors/input) - -### Temporary Variables - -Temporary data that doesn't survive restarts. Use for non-serializable objects (event emitters, connections, etc). - - - -```ts -import { actor } from "rivetkit"; - -const counter = actor({ -state: { count: 0 }, -vars: { lastAccess: 0 }, -actions: { -increment: (c) => { -c.vars.lastAccess = Date.now(); -return c.state.count += 1; -}, -}, -}); - -```` - - -```ts -import { actor } from "rivetkit"; - -const counter = actor({ - state: { count: 0 }, - createVars: () => ({ - emitter: new EventTarget(), - }), - actions: { - increment: (c) => { - c.vars.emitter.dispatchEvent(new Event("change")); - return c.state.count += 1; - }, - }, -}); -```` - - - - -[Documentation](/docs/actors/state) - -### Actions - -Actions are the primary way clients and other actors communicate with an actor. - -```ts -import { actor } from "rivetkit"; - -const counter = actor({ - state: { count: 0 }, - actions: { - increment: (c, amount: number) => (c.state.count += amount), - getCount: (c) => c.state.count, - }, -}); -``` - -[Documentation](/docs/actors/actions) - -### Events & Broadcasts - -Events enable real-time communication from actors to connected clients. - -```ts -import { actor, event } from "rivetkit"; - -const chatRoom = actor({ - state: { messages: [] as string[] }, - events: { - newMessage: event<{ text: string }>(), - }, - actions: { - sendMessage: (c, text: string) => { - // Broadcast to ALL connected clients - c.broadcast("newMessage", { text }); - }, - }, -}); -``` - -[Documentation](/docs/actors/events) - -### 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. `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. - - - -```ts -import { actor } from "rivetkit"; - -const chatRoom = actor({ -state: {}, -connState: { visitorId: 0 }, -onConnect: (c, conn) => { -conn.state.visitorId = Math.random(); -}, -actions: { -whoAmI: (c) => c.conn.state.visitorId, -}, -}); - -```` - - -```ts -import { actor } from "rivetkit"; - -const chatRoom = actor({ - state: {}, - // params passed from client - createConnState: (c, params: { userId: string }) => ({ - userId: params.userId, - }), - actions: { - // Access current connection's state and params - whoAmI: (c) => ({ - state: c.conn.state, - params: c.conn.params, - }), - // Iterate all connections with c.conns - notifyOthers: (c, text: string) => { - for (const conn of c.conns.values()) { - if (conn !== c.conn) conn.send("notification", { text }); - } - }, - }, -}); -```` - - - - -[Documentation](/docs/actors/connections) - -### Queues - -Use queues to process durable messages in order inside a `run` loop. - -```ts -import { actor, queue } from "rivetkit"; - -const counter = actor({ - state: { value: 0 }, - queues: { - increment: queue<{ amount: number }>(), - }, - run: async (c) => { - for await (const message of c.queue.iter()) { - c.state.value += message.body.amount; - } - }, -}); -``` - -[Documentation](/docs/actors/queues) - -### Workflows - -Use workflows when your `run` logic needs durable, replayable multi-step execution. - -```ts -import { actor, queue } from "rivetkit"; -import { workflow } from "rivetkit/workflow"; - -const worker = actor({ - state: { processed: 0 }, - queues: { - tasks: queue<{ url: string }>(), - }, - run: workflow(async (ctx) => { - await ctx.loop("task-loop", async (loopCtx) => { - const message = await loopCtx.queue.next("wait-task"); - - await loopCtx.step("process-task", async () => { - await processTask(message.body.url); - loopCtx.state.processed += 1; - }); - }); - }), -}); - -async function processTask(url: string): Promise { - const res = await fetch(url, { method: "POST" }); - if (!res.ok) throw new Error(`Task failed: ${res.status}`); -} -``` - -[Documentation](/docs/actors/workflows) - -### Actor-to-Actor Communication - -Actors can call other actors using `c.client()`. - -```ts -import { actor, setup } from "rivetkit"; - -const inventory = actor({ - state: { stock: 100 }, - actions: { - reserve: (c, amount: number) => { - c.state.stock -= amount; - }, - }, -}); - -const order = actor({ - state: {}, - actions: { - process: async (c) => { - const client = c.client(); - await client.inventory.getOrCreate(["main"]).reserve(1); - }, - }, -}); - -const registry = setup({ use: { inventory, order } }); -``` - -[Documentation](/docs/actors/communicating-between-actors) - -### Scheduling - -Schedule actions to run after a delay or at a specific time. Schedules persist across restarts, upgrades, and crashes. - -```ts -import { actor, event } from "rivetkit"; - -const reminder = actor({ - state: { message: "" }, - events: { - reminder: event<{ message: string }>(), - }, - actions: { - // Schedule action to run after delay (ms) - setReminder: (c, message: string, delayMs: number) => { - c.state.message = message; - c.schedule.after(delayMs, "sendReminder"); - }, - // Schedule action to run at specific timestamp - setReminderAt: (c, message: string, timestamp: number) => { - c.state.message = message; - c.schedule.at(timestamp, "sendReminder"); - }, - sendReminder: (c) => { - c.broadcast("reminder", { message: c.state.message }); - }, - }, -}); -``` - -[Documentation](/docs/actors/schedule) - -### Destroying Actors - -Permanently delete an actor and its state using `c.destroy()`. - -```ts -import { actor } from "rivetkit"; - -const userAccount = actor({ - state: { email: "", name: "" }, - onDestroy: (c) => { - console.log(`Account ${c.state.email} deleted`); - }, - actions: { - deleteAccount: (c) => { - c.destroy(); - }, - }, -}); -``` - -[Documentation](/docs/actors/destroy) - -### Lifecycle Hooks - -Actors support hooks for initialization, background processing, connections, networking, and state changes. Use `run` for long-lived background loops, and use `c.aborted` or `c.abortSignal` for graceful shutdown. - -```ts -import { actor, event, queue } from "rivetkit"; - -interface RoomState { - users: Record; - name?: string; -} - -interface RoomInput { - roomName: string; -} - -interface ConnState { - userId: string; - joinedAt: number; -} - -const chatRoom = actor({ - events: { - stateChanged: event(), - }, - queues: { - work: queue<{ task: string }>(), - }, - - // State & vars initialization - createState: (c, input: RoomInput): RoomState => ({ - users: {}, - name: input.roomName, - }), - createVars: () => ({ startTime: Date.now() }), - - // Actor lifecycle - onCreate: (c) => console.log("created", c.key), - onDestroy: (c) => console.log("destroyed"), - onWake: (c) => console.log("actor started"), - onSleep: (c) => console.log("actor sleeping"), - run: async (c) => { - for await (const message of c.queue.iter()) { - console.log("processing", message.body.task); - } - }, - onStateChange: (c, newState) => c.broadcast("stateChanged", newState), - - // Connection lifecycle - createConnState: (c, params): ConnState => ({ - userId: (params as { userId: string }).userId, - joinedAt: Date.now(), - }), - onBeforeConnect: (c, params) => { - /* validate auth */ - }, - onConnect: (c, conn) => console.log("connected:", conn.state.userId), - onDisconnect: (c, conn) => console.log("disconnected:", conn.state.userId), - - // Networking - onRequest: (c, req) => new Response(JSON.stringify(c.state)), - onWebSocket: (c, socket) => socket.addEventListener("message", console.log), - - // Response transformation - onBeforeActionResponse: ( - c: unknown, - name: string, - args: unknown[], - output: Out, - ): Out => output, - - actions: {}, -}); -``` - -[Documentation](/docs/actors/lifecycle) - -### Context Types - -When writing helper functions outside the actor definition, use `*ContextOf` to extract the correct context type. Helpers like `ActionContextOf`, `CreateContextOf`, `ConnContextOf`, and `ConnInitContextOf` are exported from `"rivetkit"`. Do not manually define your own context interface. Always derive it from the actor definition. - -```ts -import { actor, ActionContextOf } from "rivetkit"; - -const gameRoom = actor({ - state: { players: [] as string[], score: 0 }, - actions: { - addPlayer: (c, playerId: string) => { - validatePlayer(c, playerId); - c.state.players.push(playerId); - }, - }, -}); - -// Good: derive context type from actor definition -function validatePlayer(c: ActionContextOf, playerId: string) { - if (c.state.players.includes(playerId)) { - throw new Error("Player already in room"); - } -} - -// Bad: don't manually define context types like this -// type MyContext = { state: { players: string[] }; ... }; -``` - -[Documentation](/docs/actors/types) - -### Errors - -Use `UserError` to throw errors that are safely returned to clients. Pass `metadata` to include structured data. Other errors are converted to generic "internal error" for security. - - - -```ts -import { actor, UserError } from "rivetkit"; - -const user = actor({ -state: { 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; -}, -}, -}); - -```` - - -```ts -import { actor, setup, UserError } from "rivetkit"; -import { createClient, ActorError } from "rivetkit/client"; - -const user = actor({ - state: { 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 } }); -const client = createClient("http://localhost:6420"); - -try { - await client.user.getOrCreate([]).updateUsername("ab"); -} catch (error) { - if (error instanceof ActorError) { - console.log(error.code); // "username_too_short" - console.log(error.metadata); // { minLength: 3, actual: 2 } - } -} -```` - - - - -[Documentation](/docs/actors/errors) - -### Low-Level HTTP & WebSocket Handlers - -For custom protocols or integrating libraries that need direct access to HTTP `Request`/`Response` or WebSocket connections, use `onRequest` and `onWebSocket`. - -[HTTP Handler Documentation](/docs/actors/request-handler) · [WebSocket Handler Documentation](/docs/actors/websocket-handler) - -### Icons & Names - -Customize how actors appear in the UI with display names and icons. It's recommended to always provide a name and icon to actors in order to make them easier to distinguish in the dashboard. - -```typescript -import { actor } from "rivetkit"; - -const chatRoom = actor({ - options: { - name: "Chat Room", - icon: "💬", // or FontAwesome: "comments", "chart-line", etc. - }, - // ... -}); -``` - -[Documentation](/docs/actors/appearance) - -## Client Documentation - -Find the full client guides here: - -- [JavaScript Client](/docs/clients/javascript) -- [React Client](/docs/clients/react) -- [Swift Client](/docs/clients/swift) - -## Common Patterns - -Actors scale naturally through isolated state and message-passing. Structure your applications with these patterns: - -[Documentation](/docs/actors/design-patterns) - -### Actor Per Entity - -Create one actor per user, document, or room. Use compound keys to scope entities: - - -```ts client.ts -import { createClient } from "rivetkit/client"; -import type { registry } from "./index"; - -const client = createClient("http://localhost:6420"); - -// Single key: one actor per user -client.user.getOrCreate(["user-123"]); - -// Compound key: document scoped to an organization -client.document.getOrCreate(["org-acme", "doc-456"]); - -```` - -```ts index.ts -import { actor, setup } from "rivetkit"; - -export const user = actor({ - state: { name: "" }, - actions: {}, -}); - -export const document = actor({ - state: { content: "" }, - actions: {}, -}); - -export const registry = setup({ use: { user, document } }); - -registry.start(); -```` - - - -### Coordinator & Data Actors - -**Data actors** handle core logic (chat rooms, game sessions, user data). **Coordinator actors** track and manage collections of data actors—think of them as an index. - - -```ts index.ts -import { actor, setup } from "rivetkit"; - -// Coordinator: tracks chat rooms within an organization -export const chatRoomList = actor({ -state: { rooms: [] as string[] }, -actions: { -addRoom: async (c, name: string) => { -// Create the chat room actor -const client = c.client(); -await client.chatRoom.create([c.key[0], name]); -c.state.rooms.push(name); -}, -listRooms: (c) => c.state.rooms, -}, -}); - -// Data actor: handles a single chat room -export const chatRoom = actor({ -state: { messages: [] as string[] }, -actions: { -send: (c, msg: string) => { c.state.messages.push(msg); }, -}, -}); - -export const registry = setup({ use: { chatRoomList, chatRoom } }); - -registry.start(); - -```` - -```ts client.ts -import { createClient } from "rivetkit/client"; -import type { registry } from "./index"; - -const client = createClient("http://localhost:6420"); - -// Coordinator per org -const coordinator = client.chatRoomList.getOrCreate(["org-acme"]); -await coordinator.addRoom("general"); -await coordinator.addRoom("random"); - -// Access chat rooms created by coordinator -client.chatRoom.get(["org-acme", "general"]); -```` - - - -### Run Loop - -Use a `run` loop for continuous background work inside an actor. Process queue messages in order, run logic on intervals, stream AI responses, or coordinate long-running tasks. - -```ts -import { actor, queue, setup } from "rivetkit"; - -const counterWorker = actor({ - state: { value: 0 }, - queues: { - mutate: queue<{ delta: number }>(), - }, - run: async (c) => { - for await (const message of c.queue.iter()) { - c.state.value += message.body.delta; - } - }, - actions: { - getValue: (c) => c.state.value, - }, -}); - -const registry = setup({ use: { counterWorker } }); -``` - -### Workflow Loop - -Use this pattern for long-lived, durable workflows that initialize resources, process commands in a loop, then clean up. - -```ts -import { actor, queue, setup } from "rivetkit"; -import { Loop, workflow } from "rivetkit/workflow"; - -type WorkMessage = { amount: number }; -type ControlMessage = { type: "stop"; reason: string }; - -const worker = actor({ - state: { - phase: "idle" as "idle" | "running" | "stopped", - processed: 0, - total: 0, - stopReason: null as string | null, - }, - queues: { - work: queue(), - control: queue(), - }, - run: workflow(async (ctx) => { - await ctx.step("setup", async () => { - await fetch("https://api.example.com/workers/init", { - method: "POST", - }); - ctx.state.phase = "running"; - ctx.state.stopReason = null; - }); - - const stopReason = await ctx.loop("worker-loop", async (loopCtx) => { - const message = await loopCtx.queue.next("wait-command", { - names: ["work", "control"], - }); - - if (message.name === "work") { - await loopCtx.step("apply-work", async () => { - await fetch("https://api.example.com/workers/process", { - method: "POST", - body: JSON.stringify({ amount: message.body.amount }), - }); - loopCtx.state.processed += 1; - loopCtx.state.total += message.body.amount; - }); - return; - } - - return Loop.break((message.body as ControlMessage).reason); - }); - - await ctx.step("teardown", async () => { - await fetch("https://api.example.com/workers/shutdown", { - method: "POST", - }); - ctx.state.phase = "stopped"; - ctx.state.stopReason = stopReason; - }); - }), -}); - -const registry = setup({ use: { worker } }); -``` - -[Documentation](/docs/actors/workflows) - -### Actions vs Queues - -- **Actions** are not durable. Use them for realtime reads, ephemeral data, and low-latency communication like player input. -- **Queues** are durable. Use them to serialize mutations through the run loop, avoiding race conditions with SQLite and other local state. Callers can still wait for a response from queued work. - -### Authentication, Security, & CORS - -- Validate credentials in `onBeforeConnect` or `createConnState` and throw an error to reject unauthorized connections. -- Use `c.conn.state` to securely identify users in actions rather than trusting action parameters. -- For cross-origin access, validate the request origin in `onBeforeConnect`. - -[Authentication Documentation](/docs/actors/authentication) · [CORS Documentation](/docs/general/cors) - -### Versions & Upgrades - -When deploying new code, set a version number so Rivet can route new actors to the latest runner and optionally drain old ones. Use a build timestamp, git commit count, or CI build number as the version. It is very important to [configure versioning](/docs/actors/versions) before deploying to production. Without versioning, actors can regress by running on older runner versions, and existing actors will never be forced to migrate to new runners. They will continue running indefinitely on the old runners until they exit. - -[Documentation](/docs/actors/versions) - -### Anti-Patterns - -#### Never build a "god" actor - -Do not put all your logic in a single actor. A god actor serializes every operation through one bottleneck, kills parallelism, and makes the entire system fail as a unit. Split into focused actors per entity. - -#### Never create an actor per request - -Actors are long-lived and maintain state across requests. Creating a new actor for every incoming request throws away the core benefit of the model and wastes resources on actor creation and teardown. Use actors for persistent entities and regular functions for stateless work. - -{/* SKILL_OVERVIEW_END */} +{/* The Actors overview renders as an icon-grid landing via the DocsLanding +React component (see src/components/docs/DocsLanding.tsx + docsLandings.ts), +wired up in src/pages/docs/[...slug].astro. No markdown body here. */} diff --git a/website/src/content/docs/actors/quickstart/backend.mdx b/website/src/content/docs/actors/quickstart/backend.mdx index ea7b3559f3..b247c57664 100644 --- a/website/src/content/docs/actors/quickstart/backend.mdx +++ b/website/src/content/docs/actors/quickstart/backend.mdx @@ -28,12 +28,6 @@ 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 index c62310dea5..aaac945ec0 100644 --- a/website/src/content/docs/actors/quickstart/effect.mdx +++ b/website/src/content/docs/actors/quickstart/effect.mdx @@ -197,6 +197,62 @@ See the [JavaScript client documentation](/docs/clients/javascript) for more inf +## Feature Support + +The Effect SDK wraps the most common actor features with typed, schema-validated APIs. Everything else is still fully usable through the raw RivetKit context (see [Raw Escape Hatch](#raw-escape-hatch) below), so no feature is off limits, it just isn't typed yet. + +| Feature | Effect-native API | Access | +| --- | --- | --- | +| Actor contract & actions | `Actor.make`, `Action.make` | Typed | +| Persisted state | `State.get` / `set` / `update` / `updateAndGet` / `changes` | Typed | +| Typed client | `Actor.client`, `Client.layer` | Typed | +| Typed errors | `RivetError` | Typed | +| Logging | `Logger` | Typed | +| Sleep request | `Actor.Sleep` | Typed | +| Actor address (`actorId` / `name` / `key`) | `Actor.CurrentAddress` | Typed | +| Registry serve / test / web handler | `Registry` | Typed | +| [Events & broadcast](/docs/actors/events) | Not yet wrapped | `rawRivetkitContext.broadcast(...)` | +| [Schedule](/docs/actors/schedule) | Not yet wrapped | `rawRivetkitContext.schedule.*` | +| [Embedded SQLite](/docs/actors/sqlite) | Not yet wrapped | `rawRivetkitContext.db.execute(...)` | +| [Destroy](/docs/actors/lifecycle) | Not yet wrapped | `rawRivetkitContext.destroy()` | +| Queues, connections, vars, alarms | Not yet wrapped | `rawRivetkitContext.*` | +| Lifecycle hooks (`onSleep` / `onDestroy`) | Not yet wrapped | `rawRivetkitContext.*` | +| Raw HTTP / WebSocket handlers | Not yet wrapped | `rawRivetkitContext.*` | + +### Raw Escape Hatch + +Every wake function receives `rawRivetkitContext`, the underlying RivetKit [actor context](/docs/actors). Reach for it to use any feature that does not have a typed wrapper yet. The typed `state` argument and the raw context point at the same actor, so you can mix both: + +```ts src/actors/counter/live.ts @nocheck +export const CounterLive = Counter.toLayer( + Effect.fnUntraced(function* ({ rawRivetkitContext, state }) { + return Counter.of({ + Increment: Effect.fnUntraced(function* ({ payload }) { + // Typed state wrapper + const next = yield* State.updateAndGet(state, (current) => ({ + count: current.count + payload.amount, + })).pipe(Effect.orDie); + + // Untyped features run through the raw context + rawRivetkitContext.broadcast("newCount", next.count); + rawRivetkitContext.schedule.after(1_000, "tick", {}); + + return next.count; + }), + }); + }), + { + state: { + schema: Schema.Struct({ count: Schema.Number }), + initialValue: () => ({ count: 0 }), + }, + name: "Counter", + }, +); +``` + +Calls through `rawRivetkitContext` are not validated by `effect/Schema` and their payloads are typed as they are in the base RivetKit API. + ## Next Steps diff --git a/website/src/content/docs/actors/quickstart/index.mdx b/website/src/content/docs/actors/quickstart/index.mdx deleted file mode 100644 index 0421a0d0d0..0000000000 --- a/website/src/content/docs/actors/quickstart/index.mdx +++ /dev/null @@ -1,74 +0,0 @@ ---- -title: "Quickstart" -description: "Set up actors with Node.js, Bun, and web frameworks" -skill: false ---- - -import { - faCloudflare, - faFunction, - faLayerGroup, - faNodeJs, - faReact, - faNextjs, - faRust, -} from "@rivet-gg/icons"; - - -**Using an AI coding assistant?** Add Rivet skills for enhanced development assistance: -```sh -npx skills add rivet-dev/skills -``` - - - - - Set up actors with Node.js, Bun, and web frameworks - - - Build real-time React applications with actors - - - 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` - - - Run RivetKit on Cloudflare Workers with the WebAssembly runtime - - - Run RivetKit on Supabase Edge Functions with the WebAssembly runtime - - diff --git a/website/src/content/docs/actors/quickstart/next-js.mdx b/website/src/content/docs/actors/quickstart/next-js.mdx index f1ec9f85df..ed210a5496 100644 --- a/website/src/content/docs/actors/quickstart/next-js.mdx +++ b/website/src/content/docs/actors/quickstart/next-js.mdx @@ -140,7 +140,7 @@ For information about the Next.js client API, see the [React Client API Referenc -See the [Vercel deployment guide](/docs/connect/vercel) for detailed instructions on deploying your Rivet app to Vercel. +See the [Vercel deployment guide](/docs/deploy/vercel) for detailed instructions on deploying your Rivet app to Vercel. diff --git a/website/src/content/docs/actors/sqlite-drizzle.mdx b/website/src/content/docs/actors/sqlite-drizzle.mdx index 26bed50195..596683f80e 100644 --- a/website/src/content/docs/actors/sqlite-drizzle.mdx +++ b/website/src/content/docs/actors/sqlite-drizzle.mdx @@ -254,6 +254,6 @@ actions: { - [Drizzle SQLite quickstart](https://orm.drizzle.team/docs/get-started-sqlite) - [Drizzle `drizzle-kit generate`](https://orm.drizzle.team/docs/drizzle-kit-generate) -- [Drizzle + Cloudflare D1](https://orm.drizzle.team/docs/connect-cloudflare-d1) -- [Drizzle + Cloudflare Durable Objects](https://orm.drizzle.team/docs/connect-cloudflare-do) +- [Drizzle + Cloudflare D1](https://orm.drizzle.team/docs/deploy-cloudflare-d1) +- [Drizzle + Cloudflare Durable Objects](https://orm.drizzle.team/docs/deploy-cloudflare-do) - [Cloudflare Durable Objects SQLite storage](https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/) diff --git a/website/src/content/docs/agent-os/crash-course.mdx b/website/src/content/docs/agent-os/crash-course.mdx new file mode 100644 index 0000000000..a93e27865a --- /dev/null +++ b/website/src/content/docs/agent-os/crash-course.mdx @@ -0,0 +1,590 @@ +--- +title: "Crash Course" +description: "Run coding agents inside isolated VMs with full filesystem, process, and network control." +skill: true +--- + + +agentOS is in preview and the API is subject to change. If you run into issues, please [report them on GitHub](https://github.com/rivet-dev/rivet/issues) or [join our Discord](https://rivet.dev/discord). + + +{/* SKILL_OVERVIEW_START */} + +## Features + +- **Isolated VMs**: Each agent gets its own filesystem, processes, and networking. No shared state, no cross-contamination. +- **Multi-Agent Support**: Run Amp, Claude Code, Codex, OpenCode, and PI with a unified API. Swap agents without changing your code. +- **Host Tools**: Expose your JavaScript functions to agents as CLI commands. Direct binding with near-zero latency and automatic code mode for up to 80% token reduction. +- **Persistent State**: Filesystem and transcripts survive sleep/wake cycles automatically. No external database needed. +- **Orchestration**: Workflows, queues, cron jobs, and multi-agent coordination built on Rivet Actors. +- **Hybrid Sandboxes**: Run agents in the lightweight VM by default. Spin up a full sandbox on demand for browsers, compilation, and desktop automation. + +## When to Use agentOS + +- **Coding agents**: Run any coding agent with full OS access, file editing, shell execution, and tool use. +- **Automated pipelines**: CI-like workflows where agents clone repos, fix bugs, run tests, and open PRs. +- **Multi-agent systems**: Coordinators dispatching to specialized agents, review pipelines, planning chains. +- **Scheduled maintenance**: Cron-based agents that audit code, update dependencies, or generate reports. +- **Collaborative workspaces**: Multiple users observing and interacting with the same agent session in realtime. + +## Minimal Project + + +```ts @nocheck client.ts +import { createClient } from "rivetkit/client"; +import type { registry } from "./server"; + +const client = createClient("http://localhost:6420"); +const agent = client.vm.getOrCreate(["my-agent"]); + +// Subscribe to streaming events +agent.on("sessionEvent", (data) => { + console.log(data.event); +}); + +// Create a session and send a prompt +const session = await agent.createSession("pi", { + env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }, +}); +const response = await agent.sendPrompt( + session.sessionId, + "Write a hello world script to /home/user/hello.js", +); +console.log(response); + +// Read the file the agent created +const content = await agent.readFile("/home/user/hello.js"); +console.log(new TextDecoder().decode(content)); +``` + +```ts @nocheck server.ts +import { agentOs } from "rivetkit/agent-os"; +import { setup } from "rivetkit"; +import common from "@rivet-dev/agent-os-common"; +import pi from "@rivet-dev/agent-os-pi"; + +const vm = agentOs({ + options: { software: [common, pi] }, +}); + +export const registry = setup({ use: { vm } }); +registry.start(); +``` + + +After the quickstart, customize your agent with the [Registry](/agent-os/registry). + +## Quick Reference + +### Sessions & Transcripts + +Create agent sessions, send prompts, and stream responses in realtime. Transcripts are persisted automatically across sleep/wake cycles. + + +```ts @nocheck client.ts +import { createClient } from "rivetkit/client"; +import type { registry } from "./server"; + +const client = createClient("http://localhost:6420"); +const agent = client.vm.getOrCreate(["my-agent"]); + +// Stream events as they arrive +agent.on("sessionEvent", (data) => { + console.log(data.event.method, data.event); +}); + +// Create a session with MCP servers +const session = await agent.createSession("pi", { + env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }, + mcpServers: [ + { + type: "local", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user"], + env: {}, + }, + ], +}); + +// Send a prompt and wait for the response +const response = await agent.sendPrompt( + session.sessionId, + "List all files in the home directory", +); +console.log(response); +``` + +```ts @nocheck server.ts +import { agentOs } from "rivetkit/agent-os"; +import { setup } from "rivetkit"; +import common from "@rivet-dev/agent-os-common"; +import pi from "@rivet-dev/agent-os-pi"; + +const vm = agentOs({ + options: { software: [common, pi] }, +}); + +export const registry = setup({ use: { vm } }); +registry.start(); +``` + + +[Documentation](/docs/agent-os/sessions) + +### Permissions + +Approve or deny agent tool use with human-in-the-loop patterns or auto-approve for trusted workloads. + + +```ts @nocheck server.ts +import { agentOs } from "rivetkit/agent-os"; +import { setup } from "rivetkit"; +import common from "@rivet-dev/agent-os-common"; +import pi from "@rivet-dev/agent-os-pi"; + +// Auto-approve all permissions server-side +const vm = agentOs({ + onPermissionRequest: async (c, sessionId, request) => { + await c.respondPermission(sessionId, request.permissionId, "always"); + }, + options: { software: [common, pi] }, +}); + +export const registry = setup({ use: { vm } }); +registry.start(); +``` + +```ts @nocheck client.ts +import { createClient } from "rivetkit/client"; +import type { registry } from "./server"; + +const client = createClient("http://localhost:6420"); +const agent = client.vm.getOrCreate(["my-agent"]); + +// Or handle permissions client-side for human-in-the-loop +agent.on("permissionRequest", async (data) => { + console.log("Permission requested:", data.request); + // "once" | "always" | "reject" + await agent.respondPermission(data.sessionId, data.request.permissionId, "once"); +}); +``` + + +[Documentation](/docs/agent-os/permissions) + +### Tools + +Expose your JavaScript functions to agents as CLI commands inside the VM. Agents call them as shell commands with auto-generated flags from Zod schemas. + +```ts @nocheck +import { toolKit, hostTool } from "@rivet-dev/agent-os-core"; +import { z } from "zod"; + +const myTools = toolKit({ + name: "myapp", + description: "Application tools", + tools: { + createTicket: hostTool({ + description: "Create a ticket in the issue tracker", + inputSchema: z.object({ + title: z.string().describe("Ticket title"), + priority: z.enum(["low", "medium", "high"]).describe("Priority level"), + }), + execute: async (input) => { + const ticket = await db.tickets.create(input); + return { id: ticket.id, url: ticket.url }; + }, + }), + }, +}); + +// Agent calls: agentos-myapp createTicket --title "Fix login" --priority high +``` + +[Documentation](/docs/agent-os/tools) + +### Filesystem + +Read, write, and manage files inside the VM. The `/home/user` directory is persisted automatically across sleep/wake cycles. + + +```ts @nocheck client.ts +import { createClient } from "rivetkit/client"; +import type { registry } from "./server"; + +const client = createClient("http://localhost:6420"); +const agent = client.vm.getOrCreate(["my-agent"]); + +// Write a file +await agent.writeFile("/home/user/config.json", JSON.stringify({ key: "value" })); + +// Read a file +const content = await agent.readFile("/home/user/config.json"); +console.log(new TextDecoder().decode(content)); + +// List directory contents recursively +const files = await agent.readdirRecursive("/home/user", { maxDepth: 2 }); +console.log(files); +``` + +```ts @nocheck server.ts +import { agentOs } from "rivetkit/agent-os"; +import { setup } from "rivetkit"; +import common from "@rivet-dev/agent-os-common"; +import pi from "@rivet-dev/agent-os-pi"; + +const vm = agentOs({ + options: { software: [common, pi] }, +}); + +export const registry = setup({ use: { vm } }); +registry.start(); +``` + + +[Documentation](/docs/agent-os/filesystem) + +### Processes & Shell + +Execute commands, spawn long-running processes, and open interactive shells. + + +```ts @nocheck client.ts +import { createClient } from "rivetkit/client"; +import type { registry } from "./server"; + +const client = createClient("http://localhost:6420"); +const agent = client.vm.getOrCreate(["my-agent"]); + +// One-shot execution +const result = await agent.exec("echo hello && ls /home/user"); +console.log("stdout:", result.stdout); +console.log("exit code:", result.exitCode); + +// Spawn a long-running process +agent.on("processOutput", (data) => { + console.log(`[${data.processId}]`, data.output); +}); + +const proc = await agent.spawn("node", ["server.js"]); +console.log("Process ID:", proc.processId); +``` + +```ts @nocheck server.ts +import { agentOs } from "rivetkit/agent-os"; +import { setup } from "rivetkit"; +import common from "@rivet-dev/agent-os-common"; +import pi from "@rivet-dev/agent-os-pi"; + +const vm = agentOs({ + options: { software: [common, pi] }, +}); + +export const registry = setup({ use: { vm } }); +registry.start(); +``` + + +[Documentation](/docs/agent-os/processes) + +### Networking & Previews + +Proxy HTTP requests into VMs with `vmFetch`. Create preview URLs for port forwarding VM services to shareable public URLs. + + +```ts @nocheck client.ts +import { createClient } from "rivetkit/client"; +import type { registry } from "./server"; + +const client = createClient("http://localhost:6420"); +const agent = client.vm.getOrCreate(["my-agent"]); + +// Fetch from a service running inside the VM +const response = await agent.vmFetch(3000, "/api/health"); +console.log("Status:", response.status); + +// Create a preview URL (port forwarding to a public URL) +const preview = await agent.createSignedPreviewUrl(3000); +console.log("Public URL:", preview.path); +console.log("Expires at:", new Date(preview.expiresAt)); +``` + +```ts @nocheck server.ts +import { agentOs } from "rivetkit/agent-os"; +import { setup } from "rivetkit"; +import common from "@rivet-dev/agent-os-common"; +import pi from "@rivet-dev/agent-os-pi"; + +const vm = agentOs({ + options: { software: [common, pi] }, +}); + +export const registry = setup({ use: { vm } }); +registry.start(); +``` + + +[Documentation](/docs/agent-os/networking) + +### Cron Jobs + +Schedule recurring commands and agent sessions with cron expressions. + + +```ts @nocheck client.ts +import { createClient } from "rivetkit/client"; +import type { registry } from "./server"; + +const client = createClient("http://localhost:6420"); +const agent = client.vm.getOrCreate(["my-agent"]); + +// Schedule a command every hour +await agent.scheduleCron({ + schedule: "0 * * * *", + action: { type: "exec", command: "rm", args: ["-rf", "/tmp/cache/*"] }, +}); + +// Schedule an agent session daily at 9 AM +await agent.scheduleCron({ + schedule: "0 9 * * *", + action: { + type: "session", + agent: "pi", + prompt: "Review the codebase for security issues and write a report to /home/user/audit.md", + }, +}); +``` + +```ts @nocheck server.ts +import { agentOs } from "rivetkit/agent-os"; +import { setup } from "rivetkit"; +import common from "@rivet-dev/agent-os-common"; +import pi from "@rivet-dev/agent-os-pi"; + +const vm = agentOs({ + options: { software: [common, pi] }, +}); + +export const registry = setup({ use: { vm } }); +registry.start(); +``` + + +[Documentation](/docs/agent-os/cron) + +### Sandbox Mounting + +agentOS uses a hybrid model: agents run in a lightweight VM by default and spin up a full sandbox on demand for heavy workloads like browsers, compilation, and desktop automation. + +```ts @nocheck +import { agentOs } from "rivetkit/agent-os"; +import { setup } from "rivetkit"; +import common from "@rivet-dev/agent-os-common"; +import pi from "@rivet-dev/agent-os-pi"; + +const vm = agentOs({ + options: { software: [common, pi], + sandbox: { + enabled: true, + }, + }, +}); + +export const registry = setup({ use: { vm } }); +registry.start(); +``` + +[Documentation](/docs/agent-os/sandbox) + +### Multiplayer & Realtime + +Connect multiple clients to the same agent VM. All subscribers see session output, process logs, and shell data in realtime. + + +```ts @nocheck client.ts +import { createClient } from "rivetkit/client"; +import type { registry } from "./server"; + +// Client A: creates the session and sends prompts +const clientA = createClient("http://localhost:6420"); +const agentA = clientA.vm.getOrCreate(["shared-agent"]); +agentA.on("sessionEvent", (data) => console.log("[A]", data.event.method)); + +const session = await agentA.createSession("pi", { + env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }, +}); +await agentA.sendPrompt(session.sessionId, "Build a REST API"); + +// Client B: observes the same session (separate process) +const clientB = createClient("http://localhost:6420"); +const agentB = clientB.vm.getOrCreate(["shared-agent"]); +agentB.on("sessionEvent", (data) => console.log("[B]", data.event.method)); +// Client B sees the same events as Client A +``` + +```ts @nocheck server.ts +import { agentOs } from "rivetkit/agent-os"; +import { setup } from "rivetkit"; +import common from "@rivet-dev/agent-os-common"; +import pi from "@rivet-dev/agent-os-pi"; + +const vm = agentOs({ + options: { software: [common, pi] }, +}); + +export const registry = setup({ use: { vm } }); +registry.start(); +``` + + +[Documentation](/docs/agent-os/multiplayer) + +### Agent-to-Agent + +Compose specialized agents into pipelines. Each agent gets its own isolated VM and filesystem. + + +```ts @nocheck server.ts +import { agentOs } from "rivetkit/agent-os"; +import { setup } from "rivetkit"; +import common from "@rivet-dev/agent-os-common"; +import pi from "@rivet-dev/agent-os-pi"; + +const coder = agentOs({ + options: { software: [common, pi] }, +}); +const reviewer = agentOs({ + options: { software: [common, pi] }, +}); + +export const registry = setup({ use: { coder, reviewer } }); +registry.start(); +``` + +```ts @nocheck client.ts +import { createClient } from "rivetkit/client"; +import type { registry } from "./server"; + +const client = createClient("http://localhost:6420"); + +// Coder writes the feature +const coderAgent = client.coder.getOrCreate(["feature-auth"]); +const coderSession = await coderAgent.createSession("pi", { + env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }, +}); +await coderAgent.sendPrompt(coderSession.sessionId, "Implement the login feature"); + +// Pass files to the reviewer +const src = await coderAgent.readFile("/home/user/src/auth.ts"); +const reviewerAgent = client.reviewer.getOrCreate(["feature-auth"]); +await reviewerAgent.writeFile("/home/user/src/auth.ts", src); + +// Reviewer checks the code +const reviewSession = await reviewerAgent.createSession("pi", { + env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }, +}); +await reviewerAgent.sendPrompt( + reviewSession.sessionId, + "Review auth.ts for security issues", +); +``` + + +[Documentation](/docs/agent-os/agent-to-agent) + +### Workflows + +Orchestrate multi-step agent tasks with durable workflows that survive crashes and restarts. + +```ts @nocheck +import { agentOs } from "rivetkit/agent-os"; +import common from "@rivet-dev/agent-os-common"; +import pi from "@rivet-dev/agent-os-pi"; +import { actor, setup, workflow } from "rivetkit"; + +const automator = actor({ + workflows: { + fixBug: workflow<{ repo: string; issue: string }>(), + }, + run: async (c) => { + for await (const message of c.workflow.iter("fixBug")) { + const { repo, issue } = message.body; + const agentHandle = c.actors.vm.getOrCreate([`fix-${issue}`]); + + await c.step("clone-repo", async () => { + return agentHandle.exec(`git clone ${repo} /home/user/repo`); + }); + + await c.step("fix-bug", async () => { + const session = await agentHandle.createSession("pi", { + env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }, + }); + const response = await agentHandle.sendPrompt( + session.sessionId, + `Fix the bug described in issue: ${issue}`, + ); + await agentHandle.closeSession(session.sessionId); + return response; + }); + + await c.step("run-tests", async () => { + return agentHandle.exec("cd /home/user/repo && npm test"); + }); + + await message.complete(); + } + }, +}); + +const vm = agentOs({ + options: { software: [common, pi] }, +}); + +export const registry = setup({ use: { automator, vm } }); +registry.start(); +``` + +[Documentation](/docs/agent-os/workflows) + +### SQLite + +Use actor-local SQLite as structured long-term memory that persists across sessions and sleep/wake cycles. + +```ts @nocheck +import { actor, setup } from "rivetkit"; +import { db } from "rivetkit/db"; + +const memoryAgent = actor({ + db: db({ + onMigrate: async (db) => { + await db.execute(` + CREATE TABLE IF NOT EXISTS memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + category TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + `); + }, + }), + actions: { + store: async (c, sessionId: string, category: string, content: string) => { + await c.db.execute( + "INSERT INTO memories (session_id, category, content, created_at) VALUES (?, ?, ?, ?)", + sessionId, category, content, Date.now(), + ); + }, + search: async (c, query: string) => { + return c.db.execute( + "SELECT category, content FROM memories WHERE content LIKE ? ORDER BY created_at DESC LIMIT 20", + `%${query}%`, + ); + }, + }, +}); +``` + +[Documentation](/docs/agent-os/sqlite) + +{/* SKILL_OVERVIEW_END */} diff --git a/website/src/content/docs/agent-os/index.mdx b/website/src/content/docs/agent-os/index.mdx index d72cbaa6dc..8a01fc0479 100644 --- a/website/src/content/docs/agent-os/index.mdx +++ b/website/src/content/docs/agent-os/index.mdx @@ -1,602 +1,10 @@ --- -title: "Overview" +title: "Introduction" description: "Run coding agents inside isolated VMs with full filesystem, process, and network control." -skill: true +skill: false --- -import { faRocket } from "@rivet-gg/icons"; - - -agentOS is in preview and the API is subject to change. If you run into issues, please [report them on GitHub](https://github.com/rivet-dev/rivet/issues) or [join our Discord](https://rivet.dev/discord). - - -For a worked pattern guide, see the cookbook: [AI Agent Workspaces](/cookbook/ai-agent-workspace/). - -## Quickstart - - - - Boot a VM and run your first coding agent in minutes - - - -{/* SKILL_OVERVIEW_START */} - -## Features - -- **Isolated VMs**: Each agent gets its own filesystem, processes, and networking. No shared state, no cross-contamination. -- **Multi-Agent Support**: Run Amp, Claude Code, Codex, OpenCode, and PI with a unified API. Swap agents without changing your code. -- **Host Tools**: Expose your JavaScript functions to agents as CLI commands. Direct binding with near-zero latency and automatic code mode for up to 80% token reduction. -- **Persistent State**: Filesystem and transcripts survive sleep/wake cycles automatically. No external database needed. -- **Orchestration**: Workflows, queues, cron jobs, and multi-agent coordination built on Rivet Actors. -- **Hybrid Sandboxes**: Run agents in the lightweight VM by default. Spin up a full sandbox on demand for browsers, compilation, and desktop automation. - -## When to Use agentOS - -- **Coding agents**: Run any coding agent with full OS access, file editing, shell execution, and tool use. -- **Automated pipelines**: CI-like workflows where agents clone repos, fix bugs, run tests, and open PRs. -- **Multi-agent systems**: Coordinators dispatching to specialized agents, review pipelines, planning chains. -- **Scheduled maintenance**: Cron-based agents that audit code, update dependencies, or generate reports. -- **Collaborative workspaces**: Multiple users observing and interacting with the same agent session in realtime. - -## Minimal Project - - -```ts @nocheck client.ts -import { createClient } from "rivetkit/client"; -import type { registry } from "./server"; - -const client = createClient("http://localhost:6420"); -const agent = client.vm.getOrCreate(["my-agent"]); - -// Subscribe to streaming events -agent.on("sessionEvent", (data) => { - console.log(data.event); -}); - -// Create a session and send a prompt -const session = await agent.createSession("pi", { - env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }, -}); -const response = await agent.sendPrompt( - session.sessionId, - "Write a hello world script to /home/user/hello.js", -); -console.log(response); - -// Read the file the agent created -const content = await agent.readFile("/home/user/hello.js"); -console.log(new TextDecoder().decode(content)); -``` - -```ts @nocheck server.ts -import { agentOs } from "rivetkit/agent-os"; -import { setup } from "rivetkit"; -import common from "@rivet-dev/agent-os-common"; -import pi from "@rivet-dev/agent-os-pi"; - -const vm = agentOs({ - options: { software: [common, pi] }, -}); - -export const registry = setup({ use: { vm } }); -registry.start(); -``` - - -After the quickstart, customize your agent with the [Registry](/agent-os/registry). - -## Quick Reference - -### Sessions & Transcripts - -Create agent sessions, send prompts, and stream responses in realtime. Transcripts are persisted automatically across sleep/wake cycles. - - -```ts @nocheck client.ts -import { createClient } from "rivetkit/client"; -import type { registry } from "./server"; - -const client = createClient("http://localhost:6420"); -const agent = client.vm.getOrCreate(["my-agent"]); - -// Stream events as they arrive -agent.on("sessionEvent", (data) => { - console.log(data.event.method, data.event); -}); - -// Create a session with MCP servers -const session = await agent.createSession("pi", { - env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }, - mcpServers: [ - { - type: "local", - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user"], - env: {}, - }, - ], -}); - -// Send a prompt and wait for the response -const response = await agent.sendPrompt( - session.sessionId, - "List all files in the home directory", -); -console.log(response); -``` - -```ts @nocheck server.ts -import { agentOs } from "rivetkit/agent-os"; -import { setup } from "rivetkit"; -import common from "@rivet-dev/agent-os-common"; -import pi from "@rivet-dev/agent-os-pi"; - -const vm = agentOs({ - options: { software: [common, pi] }, -}); - -export const registry = setup({ use: { vm } }); -registry.start(); -``` - - -[Documentation](/docs/agent-os/sessions) - -### Permissions - -Approve or deny agent tool use with human-in-the-loop patterns or auto-approve for trusted workloads. - - -```ts @nocheck server.ts -import { agentOs } from "rivetkit/agent-os"; -import { setup } from "rivetkit"; -import common from "@rivet-dev/agent-os-common"; -import pi from "@rivet-dev/agent-os-pi"; - -// Auto-approve all permissions server-side -const vm = agentOs({ - onPermissionRequest: async (c, sessionId, request) => { - await c.respondPermission(sessionId, request.permissionId, "always"); - }, - options: { software: [common, pi] }, -}); - -export const registry = setup({ use: { vm } }); -registry.start(); -``` - -```ts @nocheck client.ts -import { createClient } from "rivetkit/client"; -import type { registry } from "./server"; - -const client = createClient("http://localhost:6420"); -const agent = client.vm.getOrCreate(["my-agent"]); - -// Or handle permissions client-side for human-in-the-loop -agent.on("permissionRequest", async (data) => { - console.log("Permission requested:", data.request); - // "once" | "always" | "reject" - await agent.respondPermission(data.sessionId, data.request.permissionId, "once"); -}); -``` - - -[Documentation](/docs/agent-os/permissions) - -### Tools - -Expose your JavaScript functions to agents as CLI commands inside the VM. Agents call them as shell commands with auto-generated flags from Zod schemas. - -```ts @nocheck -import { toolKit, hostTool } from "@rivet-dev/agent-os-core"; -import { z } from "zod"; - -const myTools = toolKit({ - name: "myapp", - description: "Application tools", - tools: { - createTicket: hostTool({ - description: "Create a ticket in the issue tracker", - inputSchema: z.object({ - title: z.string().describe("Ticket title"), - priority: z.enum(["low", "medium", "high"]).describe("Priority level"), - }), - execute: async (input) => { - const ticket = await db.tickets.create(input); - return { id: ticket.id, url: ticket.url }; - }, - }), - }, -}); - -// Agent calls: agentos-myapp createTicket --title "Fix login" --priority high -``` - -[Documentation](/docs/agent-os/tools) - -### Filesystem - -Read, write, and manage files inside the VM. The `/home/user` directory is persisted automatically across sleep/wake cycles. - - -```ts @nocheck client.ts -import { createClient } from "rivetkit/client"; -import type { registry } from "./server"; - -const client = createClient("http://localhost:6420"); -const agent = client.vm.getOrCreate(["my-agent"]); - -// Write a file -await agent.writeFile("/home/user/config.json", JSON.stringify({ key: "value" })); - -// Read a file -const content = await agent.readFile("/home/user/config.json"); -console.log(new TextDecoder().decode(content)); - -// List directory contents recursively -const files = await agent.readdirRecursive("/home/user", { maxDepth: 2 }); -console.log(files); -``` - -```ts @nocheck server.ts -import { agentOs } from "rivetkit/agent-os"; -import { setup } from "rivetkit"; -import common from "@rivet-dev/agent-os-common"; -import pi from "@rivet-dev/agent-os-pi"; - -const vm = agentOs({ - options: { software: [common, pi] }, -}); - -export const registry = setup({ use: { vm } }); -registry.start(); -``` - - -[Documentation](/docs/agent-os/filesystem) - -### Processes & Shell - -Execute commands, spawn long-running processes, and open interactive shells. - - -```ts @nocheck client.ts -import { createClient } from "rivetkit/client"; -import type { registry } from "./server"; - -const client = createClient("http://localhost:6420"); -const agent = client.vm.getOrCreate(["my-agent"]); - -// One-shot execution -const result = await agent.exec("echo hello && ls /home/user"); -console.log("stdout:", result.stdout); -console.log("exit code:", result.exitCode); - -// Spawn a long-running process -agent.on("processOutput", (data) => { - console.log(`[${data.processId}]`, data.output); -}); - -const proc = await agent.spawn("node", ["server.js"]); -console.log("Process ID:", proc.processId); -``` - -```ts @nocheck server.ts -import { agentOs } from "rivetkit/agent-os"; -import { setup } from "rivetkit"; -import common from "@rivet-dev/agent-os-common"; -import pi from "@rivet-dev/agent-os-pi"; - -const vm = agentOs({ - options: { software: [common, pi] }, -}); - -export const registry = setup({ use: { vm } }); -registry.start(); -``` - - -[Documentation](/docs/agent-os/processes) - -### Networking & Previews - -Proxy HTTP requests into VMs with `vmFetch`. Create preview URLs for port forwarding VM services to shareable public URLs. - - -```ts @nocheck client.ts -import { createClient } from "rivetkit/client"; -import type { registry } from "./server"; - -const client = createClient("http://localhost:6420"); -const agent = client.vm.getOrCreate(["my-agent"]); - -// Fetch from a service running inside the VM -const response = await agent.vmFetch(3000, "/api/health"); -console.log("Status:", response.status); - -// Create a preview URL (port forwarding to a public URL) -const preview = await agent.createSignedPreviewUrl(3000); -console.log("Public URL:", preview.path); -console.log("Expires at:", new Date(preview.expiresAt)); -``` - -```ts @nocheck server.ts -import { agentOs } from "rivetkit/agent-os"; -import { setup } from "rivetkit"; -import common from "@rivet-dev/agent-os-common"; -import pi from "@rivet-dev/agent-os-pi"; - -const vm = agentOs({ - options: { software: [common, pi] }, -}); - -export const registry = setup({ use: { vm } }); -registry.start(); -``` - - -[Documentation](/docs/agent-os/networking) - -### Cron Jobs - -Schedule recurring commands and agent sessions with cron expressions. - - -```ts @nocheck client.ts -import { createClient } from "rivetkit/client"; -import type { registry } from "./server"; - -const client = createClient("http://localhost:6420"); -const agent = client.vm.getOrCreate(["my-agent"]); - -// Schedule a command every hour -await agent.scheduleCron({ - schedule: "0 * * * *", - action: { type: "exec", command: "rm", args: ["-rf", "/tmp/cache/*"] }, -}); - -// Schedule an agent session daily at 9 AM -await agent.scheduleCron({ - schedule: "0 9 * * *", - action: { - type: "session", - agent: "pi", - prompt: "Review the codebase for security issues and write a report to /home/user/audit.md", - }, -}); -``` - -```ts @nocheck server.ts -import { agentOs } from "rivetkit/agent-os"; -import { setup } from "rivetkit"; -import common from "@rivet-dev/agent-os-common"; -import pi from "@rivet-dev/agent-os-pi"; - -const vm = agentOs({ - options: { software: [common, pi] }, -}); - -export const registry = setup({ use: { vm } }); -registry.start(); -``` - - -[Documentation](/docs/agent-os/cron) - -### Sandbox Mounting - -agentOS uses a hybrid model: agents run in a lightweight VM by default and spin up a full sandbox on demand for heavy workloads like browsers, compilation, and desktop automation. - -```ts @nocheck -import { agentOs } from "rivetkit/agent-os"; -import { setup } from "rivetkit"; -import common from "@rivet-dev/agent-os-common"; -import pi from "@rivet-dev/agent-os-pi"; - -const vm = agentOs({ - options: { software: [common, pi], - sandbox: { - enabled: true, - }, - }, -}); - -export const registry = setup({ use: { vm } }); -registry.start(); -``` - -[Documentation](/docs/agent-os/sandbox) - -### Multiplayer & Realtime - -Connect multiple clients to the same agent VM. All subscribers see session output, process logs, and shell data in realtime. - - -```ts @nocheck client.ts -import { createClient } from "rivetkit/client"; -import type { registry } from "./server"; - -// Client A: creates the session and sends prompts -const clientA = createClient("http://localhost:6420"); -const agentA = clientA.vm.getOrCreate(["shared-agent"]); -agentA.on("sessionEvent", (data) => console.log("[A]", data.event.method)); - -const session = await agentA.createSession("pi", { - env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }, -}); -await agentA.sendPrompt(session.sessionId, "Build a REST API"); - -// Client B: observes the same session (separate process) -const clientB = createClient("http://localhost:6420"); -const agentB = clientB.vm.getOrCreate(["shared-agent"]); -agentB.on("sessionEvent", (data) => console.log("[B]", data.event.method)); -// Client B sees the same events as Client A -``` - -```ts @nocheck server.ts -import { agentOs } from "rivetkit/agent-os"; -import { setup } from "rivetkit"; -import common from "@rivet-dev/agent-os-common"; -import pi from "@rivet-dev/agent-os-pi"; - -const vm = agentOs({ - options: { software: [common, pi] }, -}); - -export const registry = setup({ use: { vm } }); -registry.start(); -``` - - -[Documentation](/docs/agent-os/multiplayer) - -### Agent-to-Agent - -Compose specialized agents into pipelines. Each agent gets its own isolated VM and filesystem. - - -```ts @nocheck server.ts -import { agentOs } from "rivetkit/agent-os"; -import { setup } from "rivetkit"; -import common from "@rivet-dev/agent-os-common"; -import pi from "@rivet-dev/agent-os-pi"; - -const coder = agentOs({ - options: { software: [common, pi] }, -}); -const reviewer = agentOs({ - options: { software: [common, pi] }, -}); - -export const registry = setup({ use: { coder, reviewer } }); -registry.start(); -``` - -```ts @nocheck client.ts -import { createClient } from "rivetkit/client"; -import type { registry } from "./server"; - -const client = createClient("http://localhost:6420"); - -// Coder writes the feature -const coderAgent = client.coder.getOrCreate(["feature-auth"]); -const coderSession = await coderAgent.createSession("pi", { - env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }, -}); -await coderAgent.sendPrompt(coderSession.sessionId, "Implement the login feature"); - -// Pass files to the reviewer -const src = await coderAgent.readFile("/home/user/src/auth.ts"); -const reviewerAgent = client.reviewer.getOrCreate(["feature-auth"]); -await reviewerAgent.writeFile("/home/user/src/auth.ts", src); - -// Reviewer checks the code -const reviewSession = await reviewerAgent.createSession("pi", { - env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }, -}); -await reviewerAgent.sendPrompt( - reviewSession.sessionId, - "Review auth.ts for security issues", -); -``` - - -[Documentation](/docs/agent-os/agent-to-agent) - -### Workflows - -Orchestrate multi-step agent tasks with durable workflows that survive crashes and restarts. - -```ts @nocheck -import { agentOs } from "rivetkit/agent-os"; -import common from "@rivet-dev/agent-os-common"; -import pi from "@rivet-dev/agent-os-pi"; -import { actor, setup, workflow } from "rivetkit"; - -const automator = actor({ - workflows: { - fixBug: workflow<{ repo: string; issue: string }>(), - }, - run: async (c) => { - for await (const message of c.workflow.iter("fixBug")) { - const { repo, issue } = message.body; - const agentHandle = c.actors.vm.getOrCreate([`fix-${issue}`]); - - await c.step("clone-repo", async () => { - return agentHandle.exec(`git clone ${repo} /home/user/repo`); - }); - - await c.step("fix-bug", async () => { - const session = await agentHandle.createSession("pi", { - env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }, - }); - const response = await agentHandle.sendPrompt( - session.sessionId, - `Fix the bug described in issue: ${issue}`, - ); - await agentHandle.closeSession(session.sessionId); - return response; - }); - - await c.step("run-tests", async () => { - return agentHandle.exec("cd /home/user/repo && npm test"); - }); - - await message.complete(); - } - }, -}); - -const vm = agentOs({ - options: { software: [common, pi] }, -}); - -export const registry = setup({ use: { automator, vm } }); -registry.start(); -``` - -[Documentation](/docs/agent-os/workflows) - -### SQLite - -Use actor-local SQLite as structured long-term memory that persists across sessions and sleep/wake cycles. - -```ts @nocheck -import { actor, setup } from "rivetkit"; -import { db } from "rivetkit/db"; - -const memoryAgent = actor({ - db: db({ - onMigrate: async (db) => { - await db.execute(` - CREATE TABLE IF NOT EXISTS memories ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - category TEXT NOT NULL, - content TEXT NOT NULL, - created_at INTEGER NOT NULL - ); - `); - }, - }), - actions: { - store: async (c, sessionId: string, category: string, content: string) => { - await c.db.execute( - "INSERT INTO memories (session_id, category, content, created_at) VALUES (?, ?, ?, ?)", - sessionId, category, content, Date.now(), - ); - }, - search: async (c, query: string) => { - return c.db.execute( - "SELECT category, content FROM memories WHERE content LIKE ? ORDER BY created_at DESC LIMIT 20", - `%${query}%`, - ); - }, - }, -}); -``` - -[Documentation](/docs/agent-os/sqlite) - -{/* SKILL_OVERVIEW_END */} +{/* The agentOS overview renders as an icon-grid landing via the DocsLanding +React component (see src/components/docs/DocsLanding.tsx + docsLandings.ts), +wired up in src/pages/docs/[...slug].astro. The crash course content lives in +agent-os/crash-course.mdx. No markdown body here. */} diff --git a/website/src/content/docs/connect/cloudflare.mdx b/website/src/content/docs/connect/cloudflare.mdx deleted file mode 100644 index 60dc516c84..0000000000 --- a/website/src/content/docs/connect/cloudflare.mdx +++ /dev/null @@ -1,154 +0,0 @@ ---- -title: "Deploying to Cloudflare Workers" -description: "Run RivetKit on Cloudflare Workers with the WebAssembly runtime." -skill: true ---- - -Cloudflare Workers run RivetKit through the WebAssembly runtime. Use the public `@rivetkit/rivetkit-wasm` package, pass the bindings through `setup({ wasm })`, and use remote SQLite. - -## Steps - - - - -- [Cloudflare account](https://dash.cloudflare.com/) -- [`wrangler`](https://developers.cloudflare.com/workers/wrangler/) configured for your account -- A Rivet namespace from the [Rivet Dashboard](https://dashboard.rivet.dev/) or a self-hosted Rivet Engine - - - - -```sh -npm install rivetkit @rivetkit/rivetkit-wasm -npm install --save-dev wrangler -``` - - - - -Set your Rivet connection values as Worker variables. The pool name must match the serverless runner configured in Rivet. - -```toml wrangler.toml -name = "rivetkit-cloudflare" -main = "src/index.ts" -compatibility_date = "2025-04-01" -compatibility_flags = ["nodejs_compat"] - -[vars] -RIVET_ENDPOINT = "https://api.rivet.dev" -RIVET_NAMESPACE = "your-namespace" -RIVET_POOL = "cloudflare-workers" -RIVET_TOKEN = "sk_..." -RIVET_PUBLIC_ENDPOINT = "https://your-namespace:pk_...@api.rivet.dev" -``` - - - - -This example uses raw SQL to keep the runtime setup visible. When `runtime: "wasm"` is used, unset SQLite defaults to remote SQLite, and `sqlite: "local"` is rejected. - - -```ts src/index.ts @nocheck -import { actor, setup } from "rivetkit"; -import * as wasmBindings from "@rivetkit/rivetkit-wasm"; -import wasmModule from "@rivetkit/rivetkit-wasm/rivetkit_wasm_bg.wasm"; - -interface Env { - RIVET_ENDPOINT: string; - RIVET_NAMESPACE: string; - RIVET_POOL: string; - RIVET_TOKEN: string; - RIVET_PUBLIC_ENDPOINT: string; -} - -interface SqliteDatabase { - run(sql: string, params?: unknown[]): Promise; - query(sql: string, params?: unknown[]): Promise<{ rows: unknown[][] }>; -} - -const rawSqlDatabaseProvider = { - createClient: async () => ({ - execute: async () => [], - close: async () => {}, - }), - onMigrate: async () => {}, -}; - -const counter = actor({ - db: rawSqlDatabaseProvider, - actions: { - increment: async (ctx, amount = 1) => { - const db = ctx.sql as SqliteDatabase; - await db.run( - "CREATE TABLE IF NOT EXISTS counters (id INTEGER PRIMARY KEY, count INTEGER NOT NULL)", - ); - await db.run( - "INSERT INTO counters (id, count) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET count = count + excluded.count", - [amount], - ); - - const result = await db.query("SELECT count FROM counters WHERE id = 1"); - return Number(result.rows[0]?.[0] ?? 0); - }, - }, -}); - -const use = { counter }; -let registry: { handler(request: Request): Promise } | undefined; - -function getRegistry(env: Env) { - registry ??= setup({ - runtime: "wasm", - sqlite: "remote", - wasm: { - bindings: wasmBindings, - initInput: wasmModule, - }, - use, - endpoint: env.RIVET_ENDPOINT, - namespace: env.RIVET_NAMESPACE, - token: env.RIVET_TOKEN, - envoy: { - poolName: env.RIVET_POOL, - }, - serverless: { - publicEndpoint: env.RIVET_PUBLIC_ENDPOINT, - }, - }); - - return registry; -} - -export default { - async fetch(request: Request, env: Env): Promise { - return await getRegistry(env).handler(request); - }, -}; -``` - - - - - -```sh -npx wrangler deploy -``` - -After deploy, set the Worker URL with the `/api/rivet` path as the serverless runner URL in Rivet. - - - - -## Runtime Notes - -- Use `runtime: "wasm"` in `setup(...)` for Workers. You can also set `RIVETKIT_RUNTIME=wasm` in environments where the registry config does not set `runtime`. -- Pass `wasm: { bindings, initInput }` explicitly from `@rivetkit/rivetkit-wasm`. -- Use remote SQLite on Workers. Leaving SQLite unset with `runtime: "wasm"` selects remote SQLite automatically. -- Keep `RIVET_PUBLIC_ENDPOINT` pointed at the client-facing Rivet endpoint. Register the Worker URL separately as the serverless runner URL. -- Local Workers runtimes must support outbound WebSockets for the Rivet envoy connection. - -## Related - -- [Quickstart](/docs/actors/quickstart) -- [Supabase Functions](/docs/connect/supabase) -- [SQLite](/docs/actors/sqlite) diff --git a/website/src/content/docs/connect/index.mdx b/website/src/content/docs/connect/index.mdx deleted file mode 100644 index 92b15eef30..0000000000 --- a/website/src/content/docs/connect/index.mdx +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: "Deploy" -description: "Rivet supports deployment to a wide range of platforms, from serverless functions to self-hosted infrastructure." -skill: false ---- - -import { deployOptions } from "@rivetkit/shared-data"; - - - - - {deployOptions.map((option) => ( - - {option.description} - - ))} - diff --git a/website/src/content/docs/connect/rivet-compute.mdx b/website/src/content/docs/connect/rivet-compute.mdx deleted file mode 100644 index e94a40bcfe..0000000000 --- a/website/src/content/docs/connect/rivet-compute.mdx +++ /dev/null @@ -1,163 +0,0 @@ ---- -title: "Deploying to Rivet Compute" -description: "Run your backend on Rivet Compute." -skill: true ---- - - -Rivet Compute is currently in beta. - - - -Using an AI coding agent? Open **Connect** on the [Rivet dashboard](https://dashboard.rivet.dev), select **Rivet Cloud**, and paste the one-shot prompt into your agent and have it connect with Rivet Compute for you. - - -## Steps - - - - -- Your RivetKit app in a GitHub repository - - If you don't have one, see the [Quickstart](/docs/actors/quickstart) page or our [Examples](https://github.com/rivet-dev/rivet/tree/main/examples) -- A [Rivet Cloud](https://dashboard.rivet.dev) account and project - - - - -Rivet Compute runs your app as a short-lived, serverless container. Make sure your server `serve()` or uses `handler()` instead of `startRunner()`: - -```typescript src/server.js @nocheck -import { registry } from "./actors.js"; -import { Hono } from "hono"; -import { serve } from "@hono/node-server"; - -const app = new Hono(); - -// Mount Rivet handler -app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); - -const PORT = parseInt(process.env.PORT); - -serve({ fetch: app.fetch, port: PORT }); -``` - -See [Runtime Modes](/docs/general/runtime-modes) for details on when to use each mode. - - - - -Create a `Dockerfile` in your project root: - -```dockerfile @nocheck -FROM node:24-alpine -WORKDIR /app -COPY package*.json ./ -RUN npm ci --omit=dev -COPY . . -CMD ["node", "src/server.js"] -``` - - - - -1. Open the [Rivet dashboard](https://dashboard.rivet.dev) and navigate to your project -2. Click **Connect** and select **Rivet Cloud** -3. Copy the **`RIVET_CLOUD_TOKEN`** value shown — this is all you need for deployment - - - - -Add `RIVET_CLOUD_TOKEN` as a secret in your GitHub repository (**Settings → Secrets and variables → Actions**), then create `.github/workflows/deploy.yml`: - -```yaml @nocheck -name: Rivet Deploy - -on: - pull_request: - types: [opened, synchronize, reopened, closed] - push: - branches: [main] - workflow_dispatch: - -concurrency: - group: rivet-deploy-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - rivet-deploy: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v4 - - uses: rivet-dev/deploy-action@v1.1.1 - with: - rivet-token: ${{ secrets.RIVET_CLOUD_TOKEN }} -``` - -The `deploy-action` handles everything automatically: - -- Builds your Docker image and pushes it to Rivet's built-in container registry -- Creates a `production` namespace on pushes to `main` -- Creates an isolated `pr-{number}` namespace for each pull request -- Posts a comment on the PR with a link to the Rivet dashboard -- Cleans up the PR namespace when the pull request is closed - - - - -The dashboard shows live status as Rivet Compute provisions your backend: - -| Status | Description | -| --- | --- | -| Provisioning | Allocating compute resources | -| Initializing | Starting the runtime environment | -| Allocating | Assigning the runner to your pool | -| Deploying | Pulling and launching your container | -| Binding | Connecting the runner to the network | -| Ready | Deployment complete | - -Once the status reaches **Ready**, your backend is live and actors are available for connections. - - - -If you are an agent monitoring the deployment via API rather than the dashboard, poll the managed-pool endpoint on the Cloud API. - -The `RIVET_CLOUD_TOKEN` secret is a `cloud_api_*` management token scoped to the Cloud API at `cloud-api.rivet.dev`. Use it for `Authorization: Bearer ...` against the Cloud API. Do not confuse it with a `pk_*` publishable key, which is scoped to the Rivet Engine API at `api.rivet.dev` and will 401 against this endpoint. - -Substitute `$CLOUD_API_URL` (typically `https://cloud-api.rivet.dev`), `$PROJECT`, `$ORG`, `$CLOUD_NAMESPACE`, and `$CLOUD_TOKEN`. - -Poll every 5 seconds until `status` is `ready`. Stop and investigate if `status` is `error`. - -```bash -curl -s "$CLOUD_API_URL/projects/$PROJECT/namespaces/$CLOUD_NAMESPACE/managed-pools/default?org=$ORG" -H "Authorization: Bearer $CLOUD_TOKEN" -``` - - - - - - -## Troubleshooting - - - - -If the status stays in **Provisioning** for more than a few minutes, verify that: - -- The `RIVET_CLOUD_TOKEN` secret is correctly set in your GitHub repository -- The GitHub Actions workflow completed without errors — check the run logs - - - - -If the status shows **Error**, check that your container starts successfully and does not exit immediately. Common causes: - -- The server file is not calling `registry.startRunner()` -- A runtime crash on startup — test the image locally with `docker run` -- The Dockerfile is not listening on the `PORT` environmental variable - - - - diff --git a/website/src/content/docs/connect/supabase.mdx b/website/src/content/docs/connect/supabase.mdx deleted file mode 100644 index 6bd00481b8..0000000000 --- a/website/src/content/docs/connect/supabase.mdx +++ /dev/null @@ -1,141 +0,0 @@ ---- -title: "Deploying to Supabase Functions" -description: "Run RivetKit on Supabase Edge Functions with the WebAssembly runtime." -skill: true ---- - -Supabase Edge Functions run RivetKit through the WebAssembly runtime. Use the public `@rivetkit/rivetkit-wasm` package, load the wasm file with Deno, and use remote SQLite. - -## Steps - - - - -- [Supabase project](https://supabase.com/) -- [Supabase CLI](https://supabase.com/docs/guides/cli) configured for your project -- A Rivet namespace from the [Rivet Dashboard](https://dashboard.rivet.dev/) or a self-hosted Rivet Engine - - - - -```sh -npx supabase functions new rivet -``` - -Add the packages used by the function: - -```sh -npm install rivetkit @rivetkit/rivetkit-wasm -``` - - - - -Supabase Functions run under Deno, so load the wasm bytes from the package export and pass them to `setup({ wasm })`. - - -```ts supabase/functions/rivet/index.ts @nocheck -import { actor, setup } from "rivetkit"; -import * as wasmBindings from "@rivetkit/rivetkit-wasm"; - -interface SqliteDatabase { - run(sql: string, params?: unknown[]): Promise; - query(sql: string, params?: unknown[]): Promise<{ rows: unknown[][] }>; -} - -const wasmModule = await Deno.readFile( - new URL(import.meta.resolve("@rivetkit/rivetkit-wasm/rivetkit_wasm_bg.wasm")), -); - -const rawSqlDatabaseProvider = { - createClient: async () => ({ - execute: async () => [], - close: async () => {}, - }), - onMigrate: async () => {}, -}; - -const counter = actor({ - db: rawSqlDatabaseProvider, - actions: { - increment: async (ctx, amount = 1) => { - const db = ctx.sql as SqliteDatabase; - await db.run( - "CREATE TABLE IF NOT EXISTS counters (id INTEGER PRIMARY KEY, count INTEGER NOT NULL)", - ); - await db.run( - "INSERT INTO counters (id, count) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET count = count + excluded.count", - [amount], - ); - - const result = await db.query("SELECT count FROM counters WHERE id = 1"); - return Number(result.rows[0]?.[0] ?? 0); - }, - }, -}); - -const registry = setup({ - runtime: "wasm", - sqlite: "remote", - wasm: { - bindings: wasmBindings, - initInput: wasmModule, - }, - use: { counter }, - endpoint: Deno.env.get("RIVET_ENDPOINT"), - namespace: Deno.env.get("RIVET_NAMESPACE"), - token: Deno.env.get("RIVET_TOKEN"), - envoy: { - poolName: Deno.env.get("RIVET_POOL") ?? "supabase-functions", - }, - serverless: { - basePath: "/rivet/api/rivet", - publicEndpoint: Deno.env.get("RIVET_PUBLIC_ENDPOINT"), - }, -}); - -Deno.serve(async (request) => { - return await registry.handler(request); -}); -``` - - - - - -Set the Rivet connection values as Supabase secrets. The pool name must match the serverless runner configured in Rivet. - -```sh -npx supabase secrets set \ - RIVET_ENDPOINT=https://api.rivet.dev \ - RIVET_PUBLIC_ENDPOINT=https://your-namespace:pk_...@api.rivet.dev \ - RIVET_NAMESPACE=your-namespace \ - RIVET_POOL=supabase-functions \ - RIVET_TOKEN=sk_... -``` - - - - -```sh -npx supabase functions deploy rivet -``` - -After deploy, set the function URL with the `/api/rivet` path as the serverless runner URL in Rivet. For a function named `rivet`, this is usually `https://your-project.functions.supabase.co/functions/v1/rivet/api/rivet`. - - - - -## Runtime Notes - -- Use `runtime: "wasm"` in `setup(...)` for Supabase Functions. You can also set `RIVETKIT_RUNTIME=wasm` in environments where the registry config does not set `runtime`. -- Pass `wasm: { bindings, initInput }` explicitly from `@rivetkit/rivetkit-wasm`. -- Use remote SQLite on Supabase Functions. Leaving SQLite unset with `runtime: "wasm"` selects remote SQLite automatically. -- Keep `RIVET_PUBLIC_ENDPOINT` pointed at the client-facing Rivet endpoint. Register the function URL separately as the serverless runner URL. -- Supabase Functions run in Deno, so load the wasm module with Deno-friendly bytes, URL, response, or module input. - -## Related - -- [Quickstart](/docs/actors/quickstart) -- [Cloudflare Workers](/docs/connect/cloudflare) -- [SQLite](/docs/actors/sqlite) diff --git a/website/src/content/docs/connect/aws-ecs.mdx b/website/src/content/docs/deploy/aws-ecs.mdx similarity index 100% rename from website/src/content/docs/connect/aws-ecs.mdx rename to website/src/content/docs/deploy/aws-ecs.mdx diff --git a/website/src/content/docs/connect/aws-lambda.mdx b/website/src/content/docs/deploy/aws-lambda.mdx similarity index 100% rename from website/src/content/docs/connect/aws-lambda.mdx rename to website/src/content/docs/deploy/aws-lambda.mdx diff --git a/website/src/content/docs/deploy/cloudflare.mdx b/website/src/content/docs/deploy/cloudflare.mdx new file mode 100644 index 0000000000..39d0f3d2b8 --- /dev/null +++ b/website/src/content/docs/deploy/cloudflare.mdx @@ -0,0 +1,68 @@ +--- +title: "Deploying to Cloudflare Workers" +description: "Deploy an existing Rivet project to Cloudflare Workers." +skill: true +--- + +This guide covers deploying an existing Rivet project to Cloudflare Workers. + +## Prerequisites + +- [Cloudflare account](https://dash.cloudflare.com/) +- [`wrangler`](https://developers.cloudflare.com/workers/wrangler/) configured for your account +- A Rivet namespace from the [Rivet Dashboard](https://dashboard.rivet.dev/) or a self-hosted Rivet Engine + +## Steps + + + + +Follow the [Cloudflare Workers Quickstart](/docs/actors/quickstart/cloudflare) to set up your project locally. + + + + +Set your Rivet connection values as Worker variables. The pool name must match the serverless runner configured in Rivet. + +```toml wrangler.toml +name = "rivetkit-cloudflare" +main = "src/index.ts" +compatibility_date = "2025-04-01" +compatibility_flags = ["nodejs_compat"] + +[vars] +RIVET_ENDPOINT = "https://api.rivet.dev" +RIVET_NAMESPACE = "your-namespace" +RIVET_POOL = "cloudflare-workers" +RIVET_TOKEN = "sk_..." +RIVET_PUBLIC_ENDPOINT = "https://your-namespace:pk_...@api.rivet.dev" +``` + + + + +```sh +npx wrangler deploy +``` + + + + +After deploy, set the Worker URL with the `/api/rivet` path as the serverless runner URL in Rivet. + + + + +## Runtime Notes + +- Use `runtime: "wasm"` in `setup(...)` for Workers. You can also set `RIVETKIT_RUNTIME=wasm` in environments where the registry config does not set `runtime`. +- Pass `wasm: { bindings, initInput }` explicitly from `@rivetkit/rivetkit-wasm`. +- Use remote SQLite on Workers. Leaving SQLite unset with `runtime: "wasm"` selects remote SQLite automatically. +- Keep `RIVET_PUBLIC_ENDPOINT` pointed at the client-facing Rivet endpoint. Register the Worker URL separately as the serverless runner URL. +- Local Workers runtimes must support outbound WebSockets for the Rivet envoy connection. + +## Related + +- [Cloudflare Workers Quickstart](/docs/actors/quickstart/cloudflare) +- [Deploying to Supabase Functions](/docs/deploy/supabase) +- [SQLite](/docs/actors/sqlite) diff --git a/website/src/content/docs/connect/custom.mdx b/website/src/content/docs/deploy/custom.mdx similarity index 100% rename from website/src/content/docs/connect/custom.mdx rename to website/src/content/docs/deploy/custom.mdx diff --git a/website/src/content/docs/connect/freestyle.mdx b/website/src/content/docs/deploy/freestyle.mdx similarity index 100% rename from website/src/content/docs/connect/freestyle.mdx rename to website/src/content/docs/deploy/freestyle.mdx diff --git a/website/src/content/docs/connect/gcp-cloud-run.mdx b/website/src/content/docs/deploy/gcp-cloud-run.mdx similarity index 100% rename from website/src/content/docs/connect/gcp-cloud-run.mdx rename to website/src/content/docs/deploy/gcp-cloud-run.mdx diff --git a/website/src/content/docs/connect/hetzner.mdx b/website/src/content/docs/deploy/hetzner.mdx similarity index 100% rename from website/src/content/docs/connect/hetzner.mdx rename to website/src/content/docs/deploy/hetzner.mdx diff --git a/website/src/content/docs/deploy/index.mdx b/website/src/content/docs/deploy/index.mdx new file mode 100644 index 0000000000..145a27c81c --- /dev/null +++ b/website/src/content/docs/deploy/index.mdx @@ -0,0 +1,9 @@ +--- +title: "Deploy" +description: "Rivet supports deployment to a wide range of platforms, from serverless functions to self-hosted infrastructure." +skill: false +--- + +{/* The Deploy overview renders as an icon-grid landing via the DocsLanding +React component (see src/components/docs/DocsLanding.tsx + docsLandings.ts), +wired up in src/pages/docs/[...slug].astro. No markdown body. */} diff --git a/website/src/content/docs/connect/kubernetes.mdx b/website/src/content/docs/deploy/kubernetes.mdx similarity index 100% rename from website/src/content/docs/connect/kubernetes.mdx rename to website/src/content/docs/deploy/kubernetes.mdx diff --git a/website/src/content/docs/connect/railway.mdx b/website/src/content/docs/deploy/railway.mdx similarity index 100% rename from website/src/content/docs/connect/railway.mdx rename to website/src/content/docs/deploy/railway.mdx diff --git a/website/src/content/docs/deploy/supabase.mdx b/website/src/content/docs/deploy/supabase.mdx new file mode 100644 index 0000000000..797f12dd2c --- /dev/null +++ b/website/src/content/docs/deploy/supabase.mdx @@ -0,0 +1,63 @@ +--- +title: "Deploying to Supabase Functions" +description: "Deploy an existing Rivet project to Supabase Edge Functions." +skill: true +--- + +This guide covers deploying an existing Rivet project to Supabase Edge Functions. + +## Prerequisites + +- [Supabase project](https://supabase.com/) +- [Supabase CLI](https://supabase.com/docs/guides/cli) configured for your project +- A Rivet namespace from the [Rivet Dashboard](https://dashboard.rivet.dev/) or a self-hosted Rivet Engine + +## Steps + + + + +Follow the [Supabase Functions Quickstart](/docs/actors/quickstart/supabase) to set up your project locally. + + + + +Set the Rivet connection values as Supabase secrets. The pool name must match the serverless runner configured in Rivet. + +```sh +npx supabase secrets set \ + RIVET_ENDPOINT=https://api.rivet.dev \ + RIVET_PUBLIC_ENDPOINT=https://your-namespace:pk_...@api.rivet.dev \ + RIVET_NAMESPACE=your-namespace \ + RIVET_POOL=supabase-functions \ + RIVET_TOKEN=sk_... +``` + + + + +```sh +npx supabase functions deploy rivet +``` + + + + +After deploy, set the function URL with the `/api/rivet` path as the serverless runner URL in Rivet. For a function named `rivet`, this is usually `https://your-project.functions.supabase.co/functions/v1/rivet/api/rivet`. + + + + +## Runtime Notes + +- Use `runtime: "wasm"` in `setup(...)` for Supabase Functions. You can also set `RIVETKIT_RUNTIME=wasm` in environments where the registry config does not set `runtime`. +- Pass `wasm: { bindings, initInput }` explicitly from `@rivetkit/rivetkit-wasm`. +- Use remote SQLite on Supabase Functions. Leaving SQLite unset with `runtime: "wasm"` selects remote SQLite automatically. +- Keep `RIVET_PUBLIC_ENDPOINT` pointed at the client-facing Rivet endpoint. Register the function URL separately as the serverless runner URL. +- Supabase Functions run in Deno, so load the wasm module with Deno-friendly bytes, URL, response, or module input. + +## Related + +- [Supabase Functions Quickstart](/docs/actors/quickstart/supabase) +- [Deploying to Cloudflare Workers](/docs/deploy/cloudflare) +- [SQLite](/docs/actors/sqlite) diff --git a/website/src/content/docs/connect/vercel.mdx b/website/src/content/docs/deploy/vercel.mdx similarity index 66% rename from website/src/content/docs/connect/vercel.mdx rename to website/src/content/docs/deploy/vercel.mdx index 404a180219..0edebed17a 100644 --- a/website/src/content/docs/connect/vercel.mdx +++ b/website/src/content/docs/deploy/vercel.mdx @@ -1,87 +1,54 @@ --- title: "Deploying to Vercel" -description: "Deploy your RivetKit app to Vercel." +description: "Deploy your Next.js Rivet app to Vercel." skill: true --- -## Steps +This guide assumes a Next.js app. - - +## Prerequisites - [Vercel account](https://vercel.com/) -- Your RivetKit app - - If you don't have one, see the [Quickstart](/docs/actors/quickstart) page or our [Examples](https://github.com/rivet-dev/rivet/tree/main/examples) +- A Next.js Rivet app - Access to the [Rivet Cloud](https://dashboard.rivet.dev/) or a [self-hosted Rivet Engine](/docs/general/self-hosting) - - +## Steps -Make sure your project is configured correctly for Vercel deployment. + + - +Follow the [Next.js Quickstart](/docs/actors/quickstart/next-js) to set up your project. - + + Your Next.js project should have the following structure: - `src/app/api/rivet/[...all]/route.ts`: RivetKit route handler -- `src/actors.ts`: Actor definitions and registry +- `src/rivet/registry.ts`: Actor definitions and registry -See the [Next.js quickstart](/docs/actors/quickstart/next-js) or the [Next.js example](https://github.com/rivet-dev/rivet/tree/main/examples/next-js) to get started. +The route handler sets `maxDuration` to extend the serverless function timeout so long-lived actor requests are not cut short: - +```ts src/app/api/rivet/[...all]/route.ts @nocheck +import { toNextHandler } from "@rivetkit/next-js"; +import { registry } from "@/rivet/registry"; - +export const maxDuration = 300; -Your Hono project needs: - -1. A `vercel.json` file with the Hono framework specified: - -```json vercel.json -{ - "framework": "hono" -} +export const { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS } = toNextHandler(registry); ``` -2. Your server file must import from `"hono"` for Vercel to recognize the framework: + + -```ts src/server.ts @nocheck -// You MUST import from "hono" for Vercel to detect this as a Hono app -import { Hono } from "hono"; -import { registry } from "./actors.ts"; +Set `RIVET_ENDPOINT` and `RIVET_PUBLIC_ENDPOINT` in your Vercel project settings using the URL auth format: -const app = new Hono(); -app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); -export default app; ``` - -3. Use `.ts` file extensions in imports and configure your `tsconfig.json`: - -```json tsconfig.json -{ - "compilerOptions": { - "allowImportingTsExtensions": true, - "rewriteRelativeImportExtensions": true - } -} +RIVET_ENDPOINT=https://my-namespace:sk_****@api.rivet.dev +RIVET_PUBLIC_ENDPOINT=https://my-namespace:pk_****@api.rivet.dev ``` -See the [Hello World example](https://github.com/rivet-dev/rivet/tree/main/examples/hello-world) for a complete example. - -For more details on Hono deployments, see [Vercel's Hono documentation](https://vercel.com/docs/frameworks/backend/hono). - - - - - -Vercel currently supports Next.js and Hono frameworks for RivetKit deployments. - -For other frameworks, consider deploying to [Railway](/docs/connect/railway), [Kubernetes](/docs/connect/kubernetes), or another platform. - - - - +`RIVET_ENDPOINT` uses the secret token for server-side access. `RIVET_PUBLIC_ENDPOINT` uses the publishable token and tells the metadata endpoint what connection info to provide to clients. @@ -182,3 +149,8 @@ If using the [preview-namespace-action](https://github.com/rivet-dev/preview-nam + +## Related + +- [Next.js Quickstart](/docs/actors/quickstart/next-js) +- [Self-Hosting](/docs/general/self-hosting) diff --git a/website/src/content/docs/connect/vm-and-bare-metal.mdx b/website/src/content/docs/deploy/vm-and-bare-metal.mdx similarity index 100% rename from website/src/content/docs/connect/vm-and-bare-metal.mdx rename to website/src/content/docs/deploy/vm-and-bare-metal.mdx diff --git a/website/src/content/docs/general/pool-configuration.mdx b/website/src/content/docs/general/pool-configuration.mdx index 780af355e8..448b0f6f17 100644 --- a/website/src/content/docs/general/pool-configuration.mdx +++ b/website/src/content/docs/general/pool-configuration.mdx @@ -13,7 +13,11 @@ There are two pool kinds: ## Setting the Configuration -Configure a pool via the dashboard, the API directly, or the TypeScript SDK: +Configure a pool from the [Rivet dashboard](https://dashboard.rivet.dev) under your namespace's runner settings. The dashboard is the recommended way to manage pool configuration. + + + +You can also set pool configuration directly through the API or the TypeScript SDK: @@ -64,6 +68,8 @@ curl -X PUT "https://api.rivet.dev/runner-configs/default?namespace=default" \ The HTTP API uses `snake_case`. The TypeScript SDK uses `camelCase`. The field names below use `camelCase`. + + ## Common Options These options apply to both `normal` and `serverless` pools. diff --git a/website/src/content/docs/general/runtime-modes.mdx b/website/src/content/docs/general/runtime-modes.mdx index c991eec3a6..4327e483aa 100644 --- a/website/src/content/docs/general/runtime-modes.mdx +++ b/website/src/content/docs/general/runtime-modes.mdx @@ -135,6 +135,8 @@ const registry = setup({ }); ``` +See [Pool Configuration](/docs/general/pool-configuration) for how pools are scaled, drained on version upgrades, and rate-limited during actor eviction. + ## Comparison | Mode | Method | Use Case | diff --git a/website/src/content/docs/quickstart/index.mdx b/website/src/content/docs/quickstart/index.mdx index 9226d7125a..289cd6daf5 100644 --- a/website/src/content/docs/quickstart/index.mdx +++ b/website/src/content/docs/quickstart/index.mdx @@ -6,12 +6,12 @@ skill: false import { faCloudflare, - faFunction, faNodeJs, faReact, faNextjs, faRust, } from "@rivet-gg/icons"; +import { faSupabase } from "@rivetkit/shared-data"; - Run RivetKit on Cloudflare Workers with the WebAssembly runtime + Run RivetKit on Cloudflare Workers - Run RivetKit on Supabase Edge Functions with the WebAssembly runtime + Run RivetKit on Supabase Edge Functions diff --git a/website/src/content/docs/self-hosting/index.mdx b/website/src/content/docs/self-hosting/index.mdx index 0136aa3413..8b9ac005ba 100644 --- a/website/src/content/docs/self-hosting/index.mdx +++ b/website/src/content/docs/self-hosting/index.mdx @@ -22,7 +22,7 @@ Rivet supports both BYOC (Bring Your Own Cloud) and self-hosting to fit your dep | **Air-Gapped Deployments** | Yes | No | | **Best For** | Air-gapped environments, strict compliance, custom security policies | All other production deployments | | **Support** | [Contact sales](/sales) or community | Community, Slack, and email (varies by plan) | -| **Documentation** | Continue below | [See connect guides](/docs/connect) | +| **Documentation** | Continue below | [See connect guides](/docs/deploy) | ## Architecture # diff --git a/website/src/content/docs/self-hosting/render.mdx b/website/src/content/docs/self-hosting/render.mdx index ac085fa9ea..718095f3ac 100644 --- a/website/src/content/docs/self-hosting/render.mdx +++ b/website/src/content/docs/self-hosting/render.mdx @@ -116,7 +116,7 @@ RIVET_ENDPOINT=https://:@rivet-engine-xxxx.onrender.com RIVET_PUBLIC_ENDPOINT=https://@rivet-engine-xxxx.onrender.com ``` -See the [Connect guide](/docs/connect/custom) for more details on connecting your application. +See the [Connect guide](/docs/deploy/custom) for more details on connecting your application. ## Next Steps diff --git a/website/src/metadata/skills.ts b/website/src/metadata/skills.ts index 0cc2bcd56d..136568ce88 100644 --- a/website/src/metadata/skills.ts +++ b/website/src/metadata/skills.ts @@ -50,7 +50,7 @@ const BASE_SKILL_CONFIGS = { baseTemplate: skillBaseRivetkit, content: { collection: "docs", - docId: "actors/index", + docId: "actors/crash-course", fallbackDocIds: ["actors"], startMarker: "{/* SKILL_OVERVIEW_START */}", endMarker: "{/* SKILL_OVERVIEW_END */}", diff --git a/website/src/pages/docs/[...slug].astro b/website/src/pages/docs/[...slug].astro index 0bcbe0ad1f..aa3af00227 100644 --- a/website/src/pages/docs/[...slug].astro +++ b/website/src/pages/docs/[...slug].astro @@ -5,6 +5,8 @@ import { DocsNavigation } from '@/components/DocsNavigation'; import { DocsTableOfContents } from '@/components/DocsTableOfContents'; import { DocsPageDropdown } from '@/components/DocsPageDropdown'; import { Prose } from '@/components/Prose'; +import { DocsLanding } from '@/components/docs/DocsLanding'; +import { docsLandings } from '@/components/docs/docsLandings'; import { Icon, faPencil } from '@rivet-gg/icons'; import { sitemap } from '@/sitemap/mod'; import { findActiveTab } from '@/lib/sitemap'; @@ -49,6 +51,11 @@ const tableOfContents = headings const { title, description } = entry.data as { title: string; description: string }; const slugPath = getContentSlugPath(entry.id); +// Section overviews (actors, deploy, agent-os) render as a custom icon-grid +// landing instead of the standard prose article. The markdown chrome +// (edit button, breadcrumb, TOC, page dropdown) is suppressed for these. +const landing = docsLandings[slugPath ?? '']; +const isLanding = Boolean(landing); const fullPath = slugPath ? `/docs/${slugPath}/` : '/docs/'; const foundTab = findActiveTab(fullPath, sitemap); const parentPage = foundTab?.page?.parent; @@ -89,45 +96,55 @@ const breadcrumbSchema = {
-
-
-
- -
-
- - {parentPage && ( -
- {parentPage.title} +
+ {landing ? ( + landing.logo === 'agentos' ? ( + + ) : ( + + ) + ) : ( + +
+
+ +
- )} -

{title}

- {description &&

{description}

} - - -
- - - Edit this page - - {lastModifiedFormatted && ( - - Last updated {lastModifiedFormatted} - - )} -
+ + {parentPage && ( +
+ {parentPage.title} +
+ )} +

{title}

+ {description &&

{description}

} + +
+
+ + + Edit this page + + {lastModifiedFormatted && ( + + Last updated {lastModifiedFormatted} + + )} +
+
+ )}
- {tableOfContents.length > 0 && ( + {!isLanding && tableOfContents.length > 0 && ( diff --git a/website/src/sitemap/mod.ts b/website/src/sitemap/mod.ts index 2df20006c9..5f886c6b56 100644 --- a/website/src/sitemap/mod.ts +++ b/website/src/sitemap/mod.ts @@ -27,7 +27,6 @@ import { faFingerprint, faFloppyDisk, faForward, - faFunction, faBoxesStacked, faGear, faGlobe, @@ -82,7 +81,7 @@ import { faHardDrive, faMessages, } from "@rivet-gg/icons"; -import { deployOptions, type DeployOption } from "@rivetkit/shared-data"; +import { deployOptions, faSupabase, type DeployOption } from "@rivetkit/shared-data"; import nextjs from "@/images/vendors/next-js.svg"; import type { SidebarItem, Sitemap } from "@/lib/sitemap"; @@ -104,7 +103,7 @@ export const sitemap = [ title: "General", pages: [ { - title: "Overview", + title: "Introduction", href: "/docs/actors", icon: faSquareInfo, }, @@ -113,11 +112,6 @@ export const sitemap = [ icon: faFastForward, collapsible: true, pages: [ - { - title: "Overview", - href: "/docs/actors/quickstart", - icon: faSquareInfo, - }, { title: "Node.js & Bun", href: "/docs/actors/quickstart/backend", @@ -145,6 +139,16 @@ export const sitemap = [ icon: faLayerGroup, badge: "Beta", }, + { + title: "Cloudflare Workers", + href: "/docs/actors/quickstart/cloudflare", + icon: faCloudflare, + }, + { + title: "Supabase Functions", + href: "/docs/actors/quickstart/supabase", + icon: faSupabase, + }, ], }, ] @@ -153,7 +157,7 @@ export const sitemap = [ title: "Features", pages: [ { - title: "State & Storage", + title: "In-Memory State", href: "/docs/actors/state", icon: faFloppyDisk, }, @@ -215,6 +219,10 @@ export const sitemap = [ { title: "Concepts", pages: [ + { + title: "Crash Course", + href: "/docs/actors/crash-course", + }, { title: "Design Patterns", // icon: faLayerGroup, @@ -321,10 +329,6 @@ export const sitemap = [ title: "Debugging", href: "/docs/actors/debugging", }, - { - title: "Custom Inspector Tabs", - href: "/docs/actors/inspector-tabs", - }, { title: "Types", href: "/docs/actors/types", @@ -343,6 +347,10 @@ export const sitemap = [ title: "Icons & Names", href: "/docs/actors/appearance", }, + { + title: "Custom Inspector Tabs", + href: "/docs/actors/inspector-tabs", + }, { title: "Limits", href: "/docs/actors/limits", @@ -494,7 +502,7 @@ export const sitemap = [ title: "General", pages: [ { - title: "Overview", + title: "Introduction", href: "/docs/agent-os", icon: faSquareInfo, }, @@ -503,6 +511,11 @@ export const sitemap = [ href: "/docs/agent-os/quickstart", icon: faRocket, }, + { + title: "Crash Course", + href: "/docs/agent-os/crash-course", + icon: faLightbulb, + }, { title: "agentOS vs Sandbox", href: "/docs/agent-os/versus-sandbox", @@ -721,15 +734,15 @@ export const sitemap = [ // }, { - title: "Connect", - href: "/docs/connect", + title: "Deploy", + href: "/docs/deploy", sidebar: [ { title: "General", pages: [ { - title: "Overview", - href: "/docs/connect", + title: "Introduction", + href: "/docs/deploy", icon: faSquareInfo, }, ] From 4af65f7e0caecf7bff623ffdce5489eea16abb5d Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 12 Jun 2026 21:01:04 -0700 Subject: [PATCH 10/10] [SLOP(gpt-5)] feat(cli): add rivetkit cli [SLOP(claude-opus-4-8-medium)] refactor(cli): extract engine-process crate, rework rivet dev providers [SLOP(claude-opus-4-8-low)] docs(actors): drop dev install of @rivetkit/cli from cloudflare and supabase quickstarts --- .github/workflows/publish.yaml | 114 +- Cargo.lock | 35 + Cargo.toml | 6 + docker/build/darwin-arm64.Dockerfile | 3 + docker/build/darwin-x64.Dockerfile | 3 + docker/build/linux-arm64-musl.Dockerfile | 6 + docker/build/linux-x64-musl.Dockerfile | 6 + docker/build/windows-x64.Dockerfile | 3 + engine/packages/cli/Cargo.toml | 27 + engine/packages/cli/src/cloud.rs | 282 +++++ engine/packages/cli/src/commands/deploy.rs | 139 +++ engine/packages/cli/src/commands/dev.rs | 531 ++++++++++ engine/packages/cli/src/commands/engine.rs | 46 + engine/packages/cli/src/commands/mod.rs | 4 + engine/packages/cli/src/commands/setup_ci.rs | 33 + engine/packages/cli/src/credentials.rs | 78 ++ engine/packages/cli/src/engine_runner.rs | 50 + engine/packages/cli/src/main.rs | 60 ++ engine/packages/cli/src/templates.rs | 33 + engine/packages/cli/src/util.rs | 109 ++ engine/packages/pegboard-outbound/src/lib.rs | 38 +- .../pegboard/src/ops/runner_config/upsert.rs | 45 +- .../hello-world-cloudflare-workers/.gitignore | 4 + .../hello-world-cloudflare-workers/README.md | 29 + .../package.json | 27 + .../src/cloudflare-websocket.ts | 90 ++ .../src/index.ts | 38 + .../src/wasm.d.ts | 4 + .../tsconfig.json | 13 + .../hello-world-cloudflare-workers/turbo.json | 9 + .../wrangler.toml | 7 + .../hello-world-supabase-functions/.gitignore | 4 + .../hello-world-supabase-functions/README.md | 33 + .../package.json | 26 + .../supabase/config.toml | 4 + .../supabase/functions/rivet/deno.d.ts | 5 + .../supabase/functions/rivet/index.ts | 29 + .../tsconfig.json | 13 + .../hello-world-supabase-functions/turbo.json | 9 + frontend/packages/icons/CLAUDE.md | 8 + .../packages/icons/dist/icons/faSupabase.js | 15 + frontend/packages/icons/dist/index.js | 1 + frontend/packages/icons/manifest.json | 6 + .../packages/icons/scripts/shared-utils.js | 2 +- frontend/packages/icons/src/index.gen.js | 2 +- frontend/packages/icons/src/index.gen.ts | 2 +- frontend/packages/shared-data/src/deploy.ts | 16 +- frontend/src/app/getting-started.tsx | 51 +- pnpm-lock.yaml | 239 +++++ .../packages/engine-process/Cargo.toml | 25 + .../packages/engine-process/src/error.rs | 62 ++ .../packages/engine-process/src/lib.rs | 996 ++++++++++++++++++ .../packages/rivetkit-core/Cargo.toml | 2 + .../rivetkit-core/src/engine_process.rs | 893 +--------------- .../packages/rivetkit-core/src/error.rs | 60 -- rivetkit-typescript/packages/cli/README.md | 11 + rivetkit-typescript/packages/cli/index.d.ts | 5 + rivetkit-typescript/packages/cli/index.js | 81 ++ .../cli/npm/darwin-arm64/package.json | 19 + .../packages/cli/npm/darwin-x64/package.json | 19 + .../cli/npm/linux-arm64-musl/package.json | 19 + .../cli/npm/linux-x64-musl/package.json | 19 + .../packages/cli/npm/win32-x64/package.json | 19 + rivetkit-typescript/packages/cli/package.json | 20 + .../packages/engine-cli/index.js | 2 +- scripts/publish/src/ci/bin.ts | 14 +- scripts/publish/src/lib/npm.ts | 178 +++- scripts/publish/src/lib/packages.ts | 9 + website/src/components/docs/docsLandings.ts | 5 +- .../docs/actors/quickstart/cloudflare.mdx | 107 ++ .../docs/actors/quickstart/supabase.mdx | 92 ++ website/src/content/docs/deploy/cli.mdx | 154 +++ .../src/content/docs/deploy/cloudflare.mdx | 16 +- .../src/content/docs/deploy/rivet-compute.mdx | 130 +++ website/src/content/docs/deploy/supabase.mdx | 17 +- website/src/content/docs/quickstart/index.mdx | 4 +- website/src/sitemap/mod.ts | 7 +- 77 files changed, 4246 insertions(+), 1076 deletions(-) create mode 100644 engine/packages/cli/Cargo.toml create mode 100644 engine/packages/cli/src/cloud.rs create mode 100644 engine/packages/cli/src/commands/deploy.rs create mode 100644 engine/packages/cli/src/commands/dev.rs create mode 100644 engine/packages/cli/src/commands/engine.rs create mode 100644 engine/packages/cli/src/commands/mod.rs create mode 100644 engine/packages/cli/src/commands/setup_ci.rs create mode 100644 engine/packages/cli/src/credentials.rs create mode 100644 engine/packages/cli/src/engine_runner.rs create mode 100644 engine/packages/cli/src/main.rs create mode 100644 engine/packages/cli/src/templates.rs create mode 100644 engine/packages/cli/src/util.rs create mode 100644 examples/hello-world-cloudflare-workers/.gitignore create mode 100644 examples/hello-world-cloudflare-workers/README.md create mode 100644 examples/hello-world-cloudflare-workers/package.json create mode 100644 examples/hello-world-cloudflare-workers/src/cloudflare-websocket.ts create mode 100644 examples/hello-world-cloudflare-workers/src/index.ts create mode 100644 examples/hello-world-cloudflare-workers/src/wasm.d.ts create mode 100644 examples/hello-world-cloudflare-workers/tsconfig.json create mode 100644 examples/hello-world-cloudflare-workers/turbo.json create mode 100644 examples/hello-world-cloudflare-workers/wrangler.toml create mode 100644 examples/hello-world-supabase-functions/.gitignore create mode 100644 examples/hello-world-supabase-functions/README.md create mode 100644 examples/hello-world-supabase-functions/package.json create mode 100644 examples/hello-world-supabase-functions/supabase/config.toml create mode 100644 examples/hello-world-supabase-functions/supabase/functions/rivet/deno.d.ts create mode 100644 examples/hello-world-supabase-functions/supabase/functions/rivet/index.ts create mode 100644 examples/hello-world-supabase-functions/tsconfig.json create mode 100644 examples/hello-world-supabase-functions/turbo.json create mode 100644 frontend/packages/icons/dist/icons/faSupabase.js create mode 100644 rivetkit-rust/packages/engine-process/Cargo.toml create mode 100644 rivetkit-rust/packages/engine-process/src/error.rs create mode 100644 rivetkit-rust/packages/engine-process/src/lib.rs create mode 100644 rivetkit-typescript/packages/cli/README.md create mode 100644 rivetkit-typescript/packages/cli/index.d.ts create mode 100644 rivetkit-typescript/packages/cli/index.js create mode 100644 rivetkit-typescript/packages/cli/npm/darwin-arm64/package.json create mode 100644 rivetkit-typescript/packages/cli/npm/darwin-x64/package.json create mode 100644 rivetkit-typescript/packages/cli/npm/linux-arm64-musl/package.json create mode 100644 rivetkit-typescript/packages/cli/npm/linux-x64-musl/package.json create mode 100644 rivetkit-typescript/packages/cli/npm/win32-x64/package.json create mode 100644 rivetkit-typescript/packages/cli/package.json create mode 100644 website/src/content/docs/actors/quickstart/cloudflare.mdx create mode 100644 website/src/content/docs/actors/quickstart/supabase.mdx create mode 100644 website/src/content/docs/deploy/cli.mdx create mode 100644 website/src/content/docs/deploy/rivet-compute.mdx diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 0ec1841ede..e28c13555e 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -157,6 +157,44 @@ jobs: upload_prefix: engine platform: windows-x64 release_only: true + # Rivet Cloud CLI: 4 platforms for preview, 5 for release. The publish + # job also places the matching rivet-engine artifact next to the CLI + # binary so `rivet dev` works from the npm package. + - name: cli (linux-x64-musl) + build_target: cli + docker: docker/build/linux-x64-musl.Dockerfile + artifact: rivet-x86_64-unknown-linux-musl + upload_prefix: cli + platform: linux-x64-musl + release_only: false + - name: cli (linux-arm64-musl) + build_target: cli + docker: docker/build/linux-arm64-musl.Dockerfile + artifact: rivet-aarch64-unknown-linux-musl + upload_prefix: cli + platform: linux-arm64-musl + release_only: false + - name: cli (darwin-x64) + build_target: cli + docker: docker/build/darwin-x64.Dockerfile + artifact: rivet-x86_64-apple-darwin + upload_prefix: cli + platform: darwin-x64 + release_only: false + - name: cli (darwin-arm64) + build_target: cli + docker: docker/build/darwin-arm64.Dockerfile + artifact: rivet-aarch64-apple-darwin + upload_prefix: cli + platform: darwin-arm64 + release_only: false + - name: cli (windows-x64) + build_target: cli + docker: docker/build/windows-x64.Dockerfile + artifact: rivet-x86_64-pc-windows-gnu.exe + upload_prefix: cli + platform: windows-x64 + release_only: true runs-on: depot-ubuntu-24.04-8 permissions: contents: read @@ -363,6 +401,12 @@ jobs: path: engine-artifacts pattern: engine-* merge-multiple: true + - name: Download CLI artifacts + uses: actions/download-artifact@v4 + with: + path: cli-artifacts + pattern: cli-* + merge-multiple: true - name: Place native binaries in platform packages run: | NATIVE_DIR=rivetkit-typescript/packages/rivetkit-napi @@ -408,6 +452,72 @@ jobs: fi done + - name: Place CLI binaries in CLI platform packages + run: | + CLI_DIR=rivetkit-typescript/packages/cli/npm + declare -A TRIPLE_TO_PLATFORM=( + [rivet-x86_64-unknown-linux-musl]=linux-x64-musl + [rivet-aarch64-unknown-linux-musl]=linux-arm64-musl + [rivet-x86_64-apple-darwin]=darwin-x64 + [rivet-aarch64-apple-darwin]=darwin-arm64 + [rivet-x86_64-pc-windows-gnu.exe]=win32-x64 + ) + for f in cli-artifacts/rivet-*; do + [ -e "$f" ] || continue + filename=$(basename "$f") + platform="${TRIPLE_TO_PLATFORM[$filename]:-}" + if [ -z "$platform" ]; then + echo "Skipping CLI artifact not mapped to a platform package: $filename" + continue + fi + dest="${CLI_DIR}/${platform}" + if [ ! -d "$dest" ]; then + echo "Missing CLI platform dir: $dest" >&2 + exit 1 + fi + if [ "$platform" = "win32-x64" ]; then + cp "$f" "$dest/rivet.exe" + echo "Placed $filename -> npm/${platform}/rivet.exe" + else + cp "$f" "$dest/rivet" + chmod +x "$dest/rivet" + echo "Placed $filename -> npm/${platform}/rivet" + fi + done + + - name: Place bundled engine in CLI platform packages + run: | + CLI_DIR=rivetkit-typescript/packages/cli/npm + declare -A ENGINE_TO_PLATFORM=( + [rivet-engine-x86_64-unknown-linux-musl]=linux-x64-musl + [rivet-engine-aarch64-unknown-linux-musl]=linux-arm64-musl + [rivet-engine-x86_64-apple-darwin]=darwin-x64 + [rivet-engine-aarch64-apple-darwin]=darwin-arm64 + [rivet-engine-x86_64-pc-windows-gnu.exe]=win32-x64 + ) + for f in engine-artifacts/rivet-engine-*; do + [ -e "$f" ] || continue + filename=$(basename "$f") + platform="${ENGINE_TO_PLATFORM[$filename]:-}" + if [ -z "$platform" ]; then + echo "Skipping engine artifact not mapped to a CLI platform package: $filename" + continue + fi + dest="${CLI_DIR}/${platform}" + if [ ! -d "$dest" ]; then + echo "Missing CLI platform dir: $dest" >&2 + exit 1 + fi + if [ "$platform" = "win32-x64" ]; then + cp "$f" "$dest/rivet-engine.exe" + echo "Placed $filename -> npm/${platform}/rivet-engine.exe" + else + cp "$f" "$dest/rivet-engine" + chmod +x "$dest/rivet-engine" + echo "Placed $filename -> npm/${platform}/rivet-engine" + fi + done + - name: Bump package versions for build run: | pnpm --filter=publish exec tsx src/ci/bin.ts bump-versions \ @@ -418,7 +528,9 @@ jobs: - name: Build TypeScript packages env: SKIP_WASM_BUILD: "1" - run: pnpm build -F rivetkit -F '@rivetkit/*' -F '!@rivetkit/shared-data' -F '!@rivetkit/engine-frontend' -F '!@rivetkit/mcp-hub' -F '!@rivetkit/rivetkit-napi' -F '!@rivetkit/rivetkit-wasm' + run: | + pnpm build -F rivetkit + pnpm build -F rivetkit -F '@rivetkit/*' -F '!@rivetkit/shared-data' -F '!@rivetkit/engine-frontend' -F '!@rivetkit/mcp-hub' -F '!@rivetkit/rivetkit-napi' -F '!@rivetkit/rivetkit-wasm' # ---- shared publish (runs for all triggers) ---- - name: Finalize package versions for publish diff --git a/Cargo.lock b/Cargo.lock index 84e63204dd..ca428a9a1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5213,6 +5213,24 @@ dependencies = [ "rivet-util", ] +[[package]] +name = "rivet-cli" +version = "2.3.0-rc.12" +dependencies = [ + "anyhow", + "clap", + "dirs", + "reqwest 0.12.22", + "rivetkit-engine-process", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + [[package]] name = "rivet-config" version = "2.3.0-rc.12" @@ -6086,6 +6104,7 @@ dependencies = [ "rivet-metrics", "rivetkit-actor-persist", "rivetkit-client-protocol", + "rivetkit-engine-process", "rivetkit-inspector-protocol", "rivetkit-shared-types", "scc", @@ -6108,6 +6127,22 @@ dependencies = [ "web-time", ] +[[package]] +name = "rivetkit-engine-process" +version = "2.3.0-rc.12" +dependencies = [ + "anyhow", + "reqwest 0.12.22", + "rivet-error", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", + "tracing", + "url", +] + [[package]] name = "rivetkit-inspector-protocol" version = "2.3.0-rc.12" diff --git a/Cargo.toml b/Cargo.toml index a4d2c4d9df..5575ba6d63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "engine/packages/cache", "engine/packages/cache-purge", "engine/packages/cache-result", + "engine/packages/cli", "engine/packages/config", "engine/packages/config-schema-gen", "engine/packages/datacenter", @@ -67,6 +68,7 @@ members = [ "engine/sdks/rust/ups-protocol", "rivetkit-rust/packages/actor-persist", "rivetkit-rust/packages/client", + "rivetkit-rust/packages/engine-process", "rivetkit-rust/packages/rivetkit", "rivetkit-rust/packages/rivetkit-core", "rivetkit-rust/packages/shared-types", @@ -619,6 +621,10 @@ members = [ path = "rivetkit-rust/packages/rivetkit-core" version = "=2.3.0-rc.12" + [workspace.dependencies.rivetkit-engine-process] + path = "rivetkit-rust/packages/engine-process" + version = "=2.3.0-rc.12" + [workspace.dependencies.rivetkit-shared-types] path = "rivetkit-rust/packages/shared-types" version = "=2.3.0-rc.12" diff --git a/docker/build/darwin-arm64.Dockerfile b/docker/build/darwin-arm64.Dockerfile index a7a4858547..71c0ec23f7 100644 --- a/docker/build/darwin-arm64.Dockerfile +++ b/docker/build/darwin-arm64.Dockerfile @@ -70,6 +70,9 @@ RUN --mount=type=cache,id=cargo-registry-darwin-arm64,target=/usr/local/cargo/re if [ "$BUILD_TARGET" = "engine" ]; then \ cargo build -p rivet-engine --bin rivet-engine $CARGO_FLAG --target aarch64-apple-darwin && \ cp target/aarch64-apple-darwin/$PROFILE_DIR/rivet-engine /artifacts/rivet-engine-aarch64-apple-darwin; \ + elif [ "$BUILD_TARGET" = "cli" ]; then \ + cargo build -p rivet-cli --bin rivet $CARGO_FLAG --target aarch64-apple-darwin && \ + cp target/aarch64-apple-darwin/$PROFILE_DIR/rivet /artifacts/rivet-aarch64-apple-darwin; \ elif [ "$BUILD_TARGET" = "rivetkit-napi" ]; then \ cd rivetkit-typescript/packages/rivetkit-napi && \ NAPI_RS_CROSS_COMPILE=1 napi build --platform $CARGO_FLAG --target aarch64-apple-darwin && \ diff --git a/docker/build/darwin-x64.Dockerfile b/docker/build/darwin-x64.Dockerfile index cc797599c2..2d2433b434 100644 --- a/docker/build/darwin-x64.Dockerfile +++ b/docker/build/darwin-x64.Dockerfile @@ -70,6 +70,9 @@ RUN --mount=type=cache,id=cargo-registry-darwin-x64,target=/usr/local/cargo/regi if [ "$BUILD_TARGET" = "engine" ]; then \ cargo build -p rivet-engine --bin rivet-engine $CARGO_FLAG --target x86_64-apple-darwin && \ cp target/x86_64-apple-darwin/$PROFILE_DIR/rivet-engine /artifacts/rivet-engine-x86_64-apple-darwin; \ + elif [ "$BUILD_TARGET" = "cli" ]; then \ + cargo build -p rivet-cli --bin rivet $CARGO_FLAG --target x86_64-apple-darwin && \ + cp target/x86_64-apple-darwin/$PROFILE_DIR/rivet /artifacts/rivet-x86_64-apple-darwin; \ elif [ "$BUILD_TARGET" = "rivetkit-napi" ]; then \ cd rivetkit-typescript/packages/rivetkit-napi && \ NAPI_RS_CROSS_COMPILE=1 napi build --platform $CARGO_FLAG --target x86_64-apple-darwin && \ diff --git a/docker/build/linux-arm64-musl.Dockerfile b/docker/build/linux-arm64-musl.Dockerfile index eee8640a0a..eabcf6ee2b 100644 --- a/docker/build/linux-arm64-musl.Dockerfile +++ b/docker/build/linux-arm64-musl.Dockerfile @@ -63,7 +63,13 @@ RUN --mount=type=cache,id=cargo-registry-linux-arm64-musl,target=/usr/local/carg if [ "$BUILD_TARGET" = "engine" ]; then \ RUSTFLAGS="--cfg tokio_unstable -C target-feature=+crt-static -C link-arg=-static-libgcc" \ cargo build -p rivet-engine --bin rivet-engine $CARGO_FLAG --target aarch64-unknown-linux-musl && \ + /opt/aarch64-linux-musl-cross/bin/aarch64-linux-musl-strip target/aarch64-unknown-linux-musl/$PROFILE_DIR/rivet-engine && \ cp target/aarch64-unknown-linux-musl/$PROFILE_DIR/rivet-engine /artifacts/rivet-engine-aarch64-unknown-linux-musl; \ + elif [ "$BUILD_TARGET" = "cli" ]; then \ + RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-static-libgcc" \ + cargo build -p rivet-cli --bin rivet $CARGO_FLAG --target aarch64-unknown-linux-musl && \ + /opt/aarch64-linux-musl-cross/bin/aarch64-linux-musl-strip target/aarch64-unknown-linux-musl/$PROFILE_DIR/rivet && \ + cp target/aarch64-unknown-linux-musl/$PROFILE_DIR/rivet /artifacts/rivet-aarch64-unknown-linux-musl; \ elif [ "$BUILD_TARGET" = "rivetkit-napi" ]; then \ cd rivetkit-typescript/packages/rivetkit-napi && \ RUSTFLAGS="--cfg tokio_unstable -C target-feature=-crt-static" \ diff --git a/docker/build/linux-x64-musl.Dockerfile b/docker/build/linux-x64-musl.Dockerfile index 01e7644a54..19bcb67aea 100644 --- a/docker/build/linux-x64-musl.Dockerfile +++ b/docker/build/linux-x64-musl.Dockerfile @@ -62,7 +62,13 @@ RUN --mount=type=cache,id=cargo-registry-linux-x64-musl,target=/usr/local/cargo/ if [ "$BUILD_TARGET" = "engine" ]; then \ RUSTFLAGS="--cfg tokio_unstable -C target-feature=+crt-static -C link-arg=-static-libgcc" \ cargo build -p rivet-engine --bin rivet-engine $CARGO_FLAG --target x86_64-unknown-linux-musl && \ + /opt/x86_64-unknown-linux-musl/bin/x86_64-unknown-linux-musl-strip target/x86_64-unknown-linux-musl/$PROFILE_DIR/rivet-engine && \ cp target/x86_64-unknown-linux-musl/$PROFILE_DIR/rivet-engine /artifacts/rivet-engine-x86_64-unknown-linux-musl; \ + elif [ "$BUILD_TARGET" = "cli" ]; then \ + RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-static-libgcc" \ + cargo build -p rivet-cli --bin rivet $CARGO_FLAG --target x86_64-unknown-linux-musl && \ + /opt/x86_64-unknown-linux-musl/bin/x86_64-unknown-linux-musl-strip target/x86_64-unknown-linux-musl/$PROFILE_DIR/rivet && \ + cp target/x86_64-unknown-linux-musl/$PROFILE_DIR/rivet /artifacts/rivet-x86_64-unknown-linux-musl; \ elif [ "$BUILD_TARGET" = "rivetkit-napi" ]; then \ cd rivetkit-typescript/packages/rivetkit-napi && \ RUSTFLAGS="--cfg tokio_unstable -C target-feature=-crt-static" \ diff --git a/docker/build/windows-x64.Dockerfile b/docker/build/windows-x64.Dockerfile index bba87b0947..d55ecfe59e 100644 --- a/docker/build/windows-x64.Dockerfile +++ b/docker/build/windows-x64.Dockerfile @@ -70,6 +70,9 @@ RUN --mount=type=cache,id=cargo-registry-windows-x64,target=/usr/local/cargo/reg if [ "$BUILD_TARGET" = "engine" ]; then \ cargo build -p rivet-engine --bin rivet-engine $CARGO_FLAG --target x86_64-pc-windows-gnu && \ cp target/x86_64-pc-windows-gnu/$PROFILE_DIR/rivet-engine.exe /artifacts/rivet-engine-x86_64-pc-windows-gnu.exe; \ + elif [ "$BUILD_TARGET" = "cli" ]; then \ + cargo build -p rivet-cli --bin rivet $CARGO_FLAG --target x86_64-pc-windows-gnu && \ + cp target/x86_64-pc-windows-gnu/$PROFILE_DIR/rivet.exe /artifacts/rivet-x86_64-pc-windows-gnu.exe; \ elif [ "$BUILD_TARGET" = "rivetkit-napi" ]; then \ cd rivetkit-typescript/packages/rivetkit-napi && \ napi build --platform $CARGO_FLAG --target x86_64-pc-windows-gnu && \ diff --git a/engine/packages/cli/Cargo.toml b/engine/packages/cli/Cargo.toml new file mode 100644 index 0000000000..f943148db4 --- /dev/null +++ b/engine/packages/cli/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "rivet-cli" +publish = false +version.workspace = true +authors.workspace = true +license.workspace = true +edition.workspace = true + +[[bin]] +name = "rivet" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +dirs.workspace = true +reqwest.workspace = true +rivetkit-engine-process.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +url.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/engine/packages/cli/src/cloud.rs b/engine/packages/cli/src/cloud.rs new file mode 100644 index 0000000000..6282aa76d6 --- /dev/null +++ b/engine/packages/cli/src/cloud.rs @@ -0,0 +1,282 @@ +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use reqwest::{Method, StatusCode}; +use serde::{Deserialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use tokio::time::sleep; +use url::Url; + +use crate::{POOL_NAME, util::encode}; + +#[derive(Deserialize)] +pub struct TokenInspectResponse { + pub project: String, + pub organization: String, +} + +#[derive(Deserialize)] +pub struct NamespaceResponse { + pub namespace: Namespace, +} + +#[derive(Deserialize)] +struct NamespacesResponse { + namespaces: Vec, + pagination: Option, +} + +#[derive(Deserialize)] +struct Pagination { + cursor: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Namespace { + pub name: String, + pub display_name: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ManagedPoolResponse { + managed_pool: Option, +} + +#[derive(Deserialize)] +struct ManagedPool { + status: Option, + error: Option, +} + +#[derive(Deserialize)] +struct ManagedPoolError { + message: Option, +} + +pub struct CloudClient { + http: reqwest::Client, + base: Url, + token: String, +} + +impl CloudClient { + pub fn new(base: &str, token: String) -> Result { + Ok(Self { + http: reqwest::Client::new(), + base: Url::parse(base).context("invalid Cloud API endpoint")?, + token, + }) + } + + pub async fn request( + &self, + method: Method, + path: &str, + body: Option, + ) -> Result> { + let url = self.base.join(path.trim_start_matches('/'))?; + let mut request = self + .http + .request(method, url) + .bearer_auth(&self.token) + .header("Content-Type", "application/json"); + if let Some(body) = body { + request = request.json(&body); + } + let response = request.send().await.context("Cloud API request failed")?; + if response.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + if !status.is_success() { + bail!("Cloud API error {status}: {text}"); + } + if text.trim().is_empty() { + return Ok(None); + } + Ok(Some(serde_json::from_str(&text).with_context(|| { + format!("Cloud API returned invalid JSON for {path}") + })?)) + } + + pub async fn request_ok( + &self, + method: Method, + path: &str, + body: Option, + ) -> Result> { + let url = self.base.join(path.trim_start_matches('/'))?; + let mut request = self + .http + .request(method, url) + .bearer_auth(&self.token) + .header("Content-Type", "application/json"); + if let Some(body) = body { + request = request.json(&body); + } + let response = request.send().await.context("Cloud API request failed")?; + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + if !status.is_success() { + bail!("Cloud API error {status}: {text}"); + } + if text.trim().is_empty() { + return Ok(None); + } + Ok(Some(serde_json::from_str(&text).with_context(|| { + format!("Cloud API returned invalid JSON for {path}") + })?)) + } +} + +pub async fn ensure_namespace( + cloud: &CloudClient, + project: &str, + org: &str, + namespace: &str, +) -> Result { + let path = format!( + "/projects/{}/namespaces/{}?org={}", + encode(project), + encode(namespace), + encode(org) + ); + if let Some(response) = cloud + .request::(Method::GET, &path, None) + .await? + { + return Ok(response.namespace); + } + + let list_path = format!( + "/projects/{}/namespaces?org={}&limit=100", + encode(project), + encode(org) + ); + if let Some(response) = cloud + .request::(Method::GET, &list_path, None) + .await? + { + let _next_cursor = response.pagination.and_then(|p| p.cursor); + if let Some(found) = response.namespaces.into_iter().find(|ns| { + ns.name == namespace + || ns + .display_name + .as_ref() + .is_some_and(|display| display.eq_ignore_ascii_case(namespace)) + }) { + return Ok(found); + } + } + + tracing::info!(%namespace, "creating namespace"); + let create_path = format!( + "/projects/{}/namespaces?org={}", + encode(project), + encode(org) + ); + let response: NamespaceResponse = cloud + .request( + Method::POST, + &create_path, + Some(json!({ "displayName": namespace })), + ) + .await? + .context("namespace create returned no body")?; + Ok(response.namespace) +} + +pub async fn create_or_update_pool( + cloud: &CloudClient, + project: &str, + org: &str, + namespace: &str, + body: Value, +) -> Result<()> { + let path = format!( + "/projects/{}/namespaces/{}/managed-pools/{}?org={}", + encode(project), + encode(namespace), + POOL_NAME, + encode(org) + ); + let _: Option = cloud.request_ok(Method::PUT, &path, Some(body)).await?; + Ok(()) +} + +async fn get_pool( + cloud: &CloudClient, + project: &str, + org: &str, + namespace: &str, +) -> Result> { + let path = format!( + "/projects/{}/namespaces/{}/managed-pools/{}?org={}", + encode(project), + encode(namespace), + POOL_NAME, + encode(org) + ); + Ok(cloud + .request::(Method::GET, &path, None) + .await? + .and_then(|r| r.managed_pool)) +} + +pub async fn wait_for_pool( + cloud: &CloudClient, + project: &str, + org: &str, + namespace: &str, + throw_on_error: bool, +) -> Result<()> { + for _ in 0..180 { + let pool = get_pool(cloud, project, org, namespace) + .await? + .context("managed pool disappeared while polling")?; + let status = pool.status.unwrap_or_else(|| "unknown".to_string()); + tracing::info!(%status, "pool status"); + match status.as_str() { + "ready" => return Ok(()), + "error" if throw_on_error => { + bail!( + "managed pool entered error state: {}", + pool.error + .and_then(|e| e.message) + .unwrap_or_else(|| "unknown error".to_string()) + ); + } + "error" => return Ok(()), + _ => sleep(Duration::from_secs(2)).await, + } + } + bail!("timed out waiting for managed pool to become ready") +} + +pub fn registry_endpoint(cloud_api: &str) -> Result { + derive_endpoint(cloud_api, "registry") +} + +pub fn dashboard_endpoint(cloud_api: &str) -> Result { + derive_endpoint(cloud_api, "dashboard") +} + +fn derive_endpoint(input: &str, subdomain: &str) -> Result { + let mut url = Url::parse(input)?; + let host = url.host_str().context("endpoint missing host")?; + let next_host = if let Some(rest) = host.strip_prefix("cloud-api.") { + format!("{subdomain}.{rest}") + } else if let Some(rest) = host.strip_prefix("api.") { + format!("{subdomain}.{rest}") + } else { + format!("{subdomain}.{host}") + }; + url.set_host(Some(&next_host))?; + url.set_path(""); + url.set_query(None); + url.set_fragment(None); + Ok(url.as_str().trim_end_matches('/').to_string()) +} diff --git a/engine/packages/cli/src/commands/deploy.rs b/engine/packages/cli/src/commands/deploy.rs new file mode 100644 index 0000000000..615b0e15f0 --- /dev/null +++ b/engine/packages/cli/src/commands/deploy.rs @@ -0,0 +1,139 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use reqwest::Method; +use serde_json::json; + +use crate::{ + DEFAULT_CLOUD_API, DEFAULT_NAMESPACE, + cloud::{ + CloudClient, TokenInspectResponse, create_or_update_pool, dashboard_endpoint, + ensure_namespace, registry_endpoint, wait_for_pool, + }, + credentials::{resolve_token, write_credentials}, + util::{default_image_tag, docker_build, docker_login, encode, parse_env_vars, run_command}, +}; + +#[derive(Parser)] +pub struct Opts { + /// Rivet Cloud API token. Also writes ~/.rivet/credentials for later commands. + #[arg(long)] + token: Option, + /// Cloud namespace to deploy to. + #[arg(long, default_value = DEFAULT_NAMESPACE)] + namespace: String, + /// Override project from /tokens/api/inspect. + #[arg(long)] + project: Option, + /// Override organization from /tokens/api/inspect. + #[arg(long)] + org: Option, + /// Dockerfile to build. + #[arg(long, default_value = "Dockerfile")] + dockerfile: PathBuf, + /// Docker build context. + #[arg(long, default_value = ".")] + build_context: PathBuf, + /// Environment override, repeatable as KEY=VAL. + #[arg(long = "env")] + env_vars: Vec, + /// Skip prompts. + #[arg(long)] + yes: bool, + /// Cloud API endpoint. + #[arg(long, default_value = DEFAULT_CLOUD_API)] + cloud_api: String, + /// Image repository name in Rivet's registry. Defaults to the project slug. + #[arg(long)] + image: Option, + /// Image tag. Defaults to the current git short SHA, or a timestamp outside git. + #[arg(long)] + tag: Option, +} + +impl Opts { + pub async fn execute(self) -> Result<()> { + let token = resolve_token(self.token.as_deref())?; + if let Some(token) = &self.token { + write_credentials(token)?; + } + + if !self.dockerfile.exists() { + bail!("Dockerfile not found: {}", self.dockerfile.display()); + } + + let cloud = CloudClient::new(&self.cloud_api, token.clone())?; + tracing::info!("inspecting Rivet Cloud token"); + let inspect: TokenInspectResponse = cloud + .request(Method::GET, "/tokens/api/inspect", None) + .await? + .context("token inspect returned no body")?; + let project = self.project.unwrap_or(inspect.project); + let organization = self.org.unwrap_or(inspect.organization); + let namespace = ensure_namespace(&cloud, &project, &organization, &self.namespace).await?; + + let registry = registry_endpoint(&self.cloud_api)?; + let dashboard = dashboard_endpoint(&self.cloud_api)?; + let image_name = self.image.unwrap_or_else(|| project.clone()); + let tag = self.tag.unwrap_or_else(default_image_tag); + let image_ref = format!("{registry}/{image_name}:{tag}"); + let dashboard_url = format!( + "{dashboard}/orgs/{}/projects/{}/ns/{}?skipOnboarding=1", + encode(&organization), + encode(&project), + encode(&namespace.name) + ); + + if !self.yes { + tracing::info!( + context = %self.build_context.display(), + %project, + namespace = %namespace.name, + image = %image_ref, + "deploying" + ); + } + + tracing::info!("enabling managed pool"); + create_or_update_pool( + &cloud, + &project, + &organization, + &namespace.name, + json!({ "displayName": "Default" }), + ) + .await?; + wait_for_pool(&cloud, &project, &organization, &namespace.name, false).await?; + + tracing::info!("logging in to Rivet registry"); + docker_login(®istry, &token)?; + + tracing::info!("building Docker image"); + docker_build(&self.build_context, &self.dockerfile, &image_ref)?; + + tracing::info!("pushing Docker image"); + run_command("docker", &["push", &image_ref], None)?; + + tracing::info!("upserting managed pool"); + let mut pool_body = json!({ + "displayName": "Default", + "maxConcurrentActors": 1000, + "image": { + "repository": image_name, + "tag": tag, + }, + }); + let env_map = parse_env_vars(&self.env_vars)?; + if !env_map.is_empty() { + pool_body["environment"] = serde_json::to_value(env_map)?; + } + create_or_update_pool(&cloud, &project, &organization, &namespace.name, pool_body).await?; + wait_for_pool(&cloud, &project, &organization, &namespace.name, true).await?; + + // The dashboard URL is the command's result; print it to stdout so it + // can be captured by scripts. + println!("{dashboard_url}"); + Ok(()) + } +} diff --git a/engine/packages/cli/src/commands/dev.rs b/engine/packages/cli/src/commands/dev.rs new file mode 100644 index 0000000000..e727cf1c4c --- /dev/null +++ b/engine/packages/cli/src/commands/dev.rs @@ -0,0 +1,531 @@ +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; +use clap::{Parser, ValueEnum}; +use reqwest::Client; +use rivetkit_engine_process::EngineProcessManager; +use serde_json::json; +use tokio::process::{Child, Command}; + +use crate::{ + DEFAULT_ENGINE_ENDPOINT, LOCAL_NAMESPACE, POOL_NAME, SUPABASE_FN_DEFAULT, + engine_runner::engine_config, util::encode, +}; + +const HANDLER_METADATA_TIMEOUT: Duration = Duration::from_secs(30); +const HANDLER_METADATA_RETRY: Duration = Duration::from_millis(200); +const HANDLER_METADATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Parser)] +pub struct Opts { + /// Serverless platform preset. Omit to run a custom dev server you point at + /// with --port or --url. + #[arg(long, value_enum)] + provider: Option, + /// Handler port. Required in the default (no provider) mode unless --url is + /// set. Overrides the provider's default port. + #[arg(long)] + port: Option, + /// Supabase function name when --provider=supabase. + #[arg(long, default_value = SUPABASE_FN_DEFAULT)] + fn_name: String, + /// Explicit full handler URL. Overrides port and path construction. + #[arg(long)] + url: Option, + /// Path to a rivet-engine binary. Defaults to RIVET_ENGINE_BINARY_PATH, a + /// binary next to this CLI, a local build, or an auto-downloaded release. + #[arg(long)] + engine_binary: Option, + /// Dev server command to spawn. Everything after `--`. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + command: Vec, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +enum Provider { + /// Generic serverless handler. The CLI assigns a free port and passes it as + /// the PORT environment variable. + Serverless, + Cloudflare, + Supabase, + /// Run only the engine, do not spawn a handler. + None, +} + +impl Opts { + pub async fn execute(self) -> Result<()> { + let mut config = engine_config(self.engine_binary.clone()); + config.public_url = resolve_engine_public_url(&self)?; + if self.provider == Some(Provider::Supabase) { + config.bind_host = Some("0.0.0.0".to_string()); + } + + // Engine-only mode: start (or reuse) the engine and wait. + if matches!(self.provider, Some(Provider::None)) { + let _engine = EngineProcessManager::start_or_reuse(config).await?; + tracing::info!( + engine = DEFAULT_ENGINE_ENDPOINT, + "engine ready (no handler); press Ctrl-C to stop" + ); + tokio::signal::ctrl_c().await.context("listen for ctrl-c")?; + return Ok(()); + } + + let plan = HandlerPlan::resolve(&self)?; + + // Start (or reuse) the engine. The engine is intentionally orphaned, so + // it survives this process and a later `rivet dev` reattaches to it. + let _engine = EngineProcessManager::start_or_reuse(config).await?; + + let mut child = plan.spawn()?; + + tokio::select! { + result = wait_for_handler_metadata(&plan.handler_url) => { + if let Err(err) = result { + let _ = child.kill().await; + return Err(err); + } + } + status = child.wait() => { + let status = status.context("wait for dev server")?; + bail!("dev server exited before the Rivet handler became ready: {status}"); + } + } + + if let Err(err) = + register_runner_config(DEFAULT_ENGINE_ENDPOINT, POOL_NAME, &plan.handler_url).await + { + let _ = child.kill().await; + return Err(err); + } + + tracing::info!( + engine = DEFAULT_ENGINE_ENDPOINT, + handler = %plan.handler_url, + "rivet dev ready; press Ctrl-C to stop" + ); + + tokio::select! { + status = child.wait() => { + let status = status.context("wait for dev server")?; + if !status.success() { + bail!("dev server exited with {status}"); + } + } + _ = tokio::signal::ctrl_c() => { + tracing::info!( + "stopping dev server (engine keeps running; use `rivet engine` to manage it)" + ); + let _ = child.kill().await; + } + } + + Ok(()) + } +} + +/// Resolved spawn plan for the dev server: where it listens, the command to +/// run, and any environment the CLI injects. +#[derive(Debug)] +struct HandlerPlan { + handler_url: String, + program: String, + args: Vec, + env: Vec<(String, String)>, +} + +impl HandlerPlan { + fn resolve(opts: &Opts) -> Result { + let provider = opts.provider; + let port = resolve_port(provider, opts.port, opts.url.is_some())?; + let handler_url = match &opts.url { + Some(url) => url.clone(), + None => build_handler_url(provider, &opts.fn_name, port), + }; + + let (program, args, env) = match provider { + Some(Provider::Cloudflare) => { + let mut args = vec![ + "wrangler".to_string(), + "dev".to_string(), + "--port".to_string(), + port.to_string(), + ]; + args.extend(opts.command.iter().cloned()); + ("npx".to_string(), args, Vec::new()) + } + Some(Provider::Supabase) => { + let mut args = vec![ + "supabase".to_string(), + "functions".to_string(), + "serve".to_string(), + opts.fn_name.clone(), + "--no-verify-jwt".to_string(), + ]; + args.extend(opts.command.iter().cloned()); + ("npx".to_string(), args, Vec::new()) + } + Some(Provider::Serverless) => { + let (program, args) = split_command(&opts.command)?; + // Serverless handlers learn their port from the PORT env var. + (program, args, vec![("PORT".to_string(), port.to_string())]) + } + // Default (no provider): spawn the user's command verbatim. + None => { + let (program, args) = split_command(&opts.command)?; + (program, args, Vec::new()) + } + Some(Provider::None) => unreachable!("engine-only mode handled before resolve"), + }; + + Ok(Self { + handler_url, + program, + args, + env, + }) + } + + fn spawn(&self) -> Result { + let mut command = Command::new(&self.program); + command.args(&self.args); + for (key, value) in &self.env { + command.env(key, value); + } + command + .spawn() + .with_context(|| format!("spawn dev server `{}`", self.program)) + } +} + +/// Resolves the handler port for the given provider. Returns an error in the +/// default mode when neither a port nor an explicit URL is provided. +fn resolve_port(provider: Option, port: Option, has_url: bool) -> Result { + match provider { + Some(Provider::Cloudflare) => Ok(port.unwrap_or(8787)), + Some(Provider::Supabase) => Ok(port.unwrap_or(54321)), + Some(Provider::Serverless) => match port { + Some(port) => Ok(port), + None => pick_free_port(), + }, + // Default mode: the port is not managed by the CLI, so it must be + // provided so the runner can be registered. `0` is a sentinel that + // callers only reach when --url is set (and the port is unused). + None if has_url => Ok(port.unwrap_or(0)), + None => port.context("provide --port (or --url) for the default dev server mode"), + Some(Provider::None) => unreachable!("engine-only mode handled before resolve"), + } +} + +fn build_handler_url(provider: Option, fn_name: &str, port: u16) -> String { + match provider { + Some(Provider::Supabase) => { + format!("http://127.0.0.1:{port}/functions/v1/{fn_name}/api/rivet") + } + _ => format!("http://127.0.0.1:{port}/api/rivet"), + } +} + +fn split_command(command: &[String]) -> Result<(String, Vec)> { + let Some((program, args)) = command.split_first() else { + bail!( + "provide a dev server command after `--` (for example `rivet dev -- npm run dev`), \ + or use `--provider none` to run only the engine" + ); + }; + Ok((program.clone(), args.to_vec())) +} + +fn resolve_engine_public_url(opts: &Opts) -> Result> { + if opts.provider != Some(Provider::Supabase) { + return Ok(None); + } + + if let Some(endpoint) = read_env_value("RIVET_ENDPOINT") { + return Ok(Some(endpoint)); + } + + if let Some(env_file) = supabase_env_file(&opts.command) { + if let Some(endpoint) = read_dotenv_value(&env_file, "RIVET_ENDPOINT") { + return Ok(Some(endpoint)); + } + } + + if let Some(endpoint) = read_dotenv_value(Path::new(".env.local"), "RIVET_ENDPOINT") { + return Ok(Some(endpoint)); + } + + Ok(None) +} + +fn supabase_env_file(command: &[String]) -> Option { + command + .windows(2) + .find_map(|window| (window[0] == "--env-file").then(|| PathBuf::from(&window[1]))) +} + +fn read_env_value(key: &str) -> Option { + std::env::var(key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn read_dotenv_value(path: &Path, key: &str) -> Option { + let contents = std::fs::read_to_string(path).ok()?; + contents.lines().find_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let (name, value) = line.split_once('=')?; + if name.trim() != key { + return None; + } + Some(strip_env_quotes(value.trim()).to_string()) + }) +} + +fn strip_env_quotes(value: &str) -> &str { + if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + &value[1..value.len() - 1] + } else { + value + } +} + +/// Allocates a free TCP port for the serverless handler. There is a small +/// window between picking the port and the handler binding it, which is +/// acceptable for local development. +fn pick_free_port() -> Result { + let listener = std::net::TcpListener::bind("127.0.0.1:0") + .context("allocate a free port for the serverless handler")?; + Ok(listener.local_addr().context("read allocated port")?.port()) +} + +async fn register_runner_config(endpoint: &str, runner: &str, handler_url: &str) -> Result<()> { + let url = format!( + "{}/runner-configs/{}?namespace={}", + endpoint.trim_end_matches('/'), + encode(runner), + LOCAL_NAMESPACE + ); + let body = json!({ + "datacenters": { + "default": { + "serverless": { + "url": handler_url, + "headers": {}, + "request_lifespan": 3600, + "slots_per_runner": 1, + "min_runners": 0, + "max_runners": 100000, + "runners_margin": 0, + "metadata_poll_interval": 1000 + } + } + } + }); + let response = Client::new() + .put(url) + .header("Content-Type", "application/json") + .bearer_auth("dev") + .json(&body) + .send() + .await + .context("register local runner config")?; + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + bail!("runner config update failed: {status}: {text}"); + } + Ok(()) +} + +async fn wait_for_handler_metadata(handler_url: &str) -> Result<()> { + let metadata_url = format!("{}/metadata", handler_url.trim_end_matches('/')); + let client = Client::new(); + let deadline = Instant::now() + HANDLER_METADATA_TIMEOUT; + let mut last_error: Option = None; + + loop { + if Instant::now() >= deadline { + bail!( + "Rivet handler metadata did not become ready at {metadata_url} within {}s (last error: {})", + HANDLER_METADATA_TIMEOUT.as_secs(), + last_error.as_deref().unwrap_or("no request attempted") + ); + } + + match client + .get(&metadata_url) + .timeout(HANDLER_METADATA_REQUEST_TIMEOUT) + .send() + .await + { + Ok(response) if response.status().is_success() => return Ok(()), + Ok(response) => { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + last_error = Some(format!("HTTP {status}: {body}")); + } + Err(err) => { + last_error = Some(err.to_string()); + } + } + + tokio::time::sleep(HANDLER_METADATA_RETRY).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opts(provider: Option) -> Opts { + Opts { + provider, + port: None, + fn_name: SUPABASE_FN_DEFAULT.to_string(), + url: None, + engine_binary: None, + command: Vec::new(), + } + } + + #[test] + fn cloudflare_provider_uses_default_port_and_wrangler_command() { + let plan = HandlerPlan::resolve(&opts(Some(Provider::Cloudflare))).unwrap(); + + assert_eq!(plan.handler_url, "http://127.0.0.1:8787/api/rivet"); + assert_eq!(plan.program, "npx"); + assert_eq!(plan.args, ["wrangler", "dev", "--port", "8787"]); + assert!(plan.env.is_empty()); + } + + #[test] + fn cloudflare_provider_allows_custom_port_and_appended_args() { + let mut opts = opts(Some(Provider::Cloudflare)); + opts.port = Some(8788); + opts.command = vec!["--local-protocol".into(), "http".into()]; + + let plan = HandlerPlan::resolve(&opts).unwrap(); + + assert_eq!(plan.handler_url, "http://127.0.0.1:8788/api/rivet"); + assert_eq!( + plan.args, + [ + "wrangler", + "dev", + "--port", + "8788", + "--local-protocol", + "http" + ] + ); + } + + #[test] + fn supabase_provider_uses_default_port_function_and_no_verify_jwt() { + let plan = HandlerPlan::resolve(&opts(Some(Provider::Supabase))).unwrap(); + + assert_eq!( + plan.handler_url, + "http://127.0.0.1:54321/functions/v1/rivet/api/rivet" + ); + assert_eq!(plan.program, "npx"); + assert_eq!( + plan.args, + ["supabase", "functions", "serve", "rivet", "--no-verify-jwt"] + ); + assert!(plan.env.is_empty()); + } + + #[test] + fn supabase_provider_allows_custom_function_port_and_appended_args() { + let mut opts = opts(Some(Provider::Supabase)); + opts.port = Some(4000); + opts.fn_name = "actors".into(); + opts.command = vec!["--env-file".into(), ".env.local".into()]; + + let plan = HandlerPlan::resolve(&opts).unwrap(); + + assert_eq!( + plan.handler_url, + "http://127.0.0.1:4000/functions/v1/actors/api/rivet" + ); + assert_eq!( + plan.args, + [ + "supabase", + "functions", + "serve", + "actors", + "--no-verify-jwt", + "--env-file", + ".env.local" + ] + ); + } + + #[test] + fn serverless_provider_injects_port_env_for_command() { + let mut opts = opts(Some(Provider::Serverless)); + opts.port = Some(3001); + opts.command = vec!["node".into(), "handler.js".into()]; + + let plan = HandlerPlan::resolve(&opts).unwrap(); + + assert_eq!(plan.handler_url, "http://127.0.0.1:3001/api/rivet"); + assert_eq!(plan.program, "node"); + assert_eq!(plan.args, ["handler.js"]); + assert_eq!(plan.env, [("PORT".to_string(), "3001".to_string())]); + } + + #[test] + fn default_mode_requires_port_or_url() { + let mut opts = opts(None); + opts.command = vec!["npm".into(), "run".into(), "dev".into()]; + + let error = HandlerPlan::resolve(&opts).unwrap_err().to_string(); + + assert!(error.contains("provide --port")); + } + + #[test] + fn explicit_url_overrides_handler_url() { + let mut opts = opts(None); + opts.url = Some("http://127.0.0.1:9000/custom".into()); + opts.command = vec!["npm".into(), "run".into(), "dev".into()]; + + let plan = HandlerPlan::resolve(&opts).unwrap(); + + assert_eq!(plan.handler_url, "http://127.0.0.1:9000/custom"); + assert_eq!(plan.program, "npm"); + assert_eq!(plan.args, ["run", "dev"]); + } + + #[test] + fn supabase_public_engine_url_reads_passed_env_file() { + let temp = tempfile::tempdir().unwrap(); + let env_path = temp.path().join(".env.local"); + std::fs::write( + &env_path, + "RIVET_ENDPOINT=\"http://host.docker.internal:6420\"\n", + ) + .unwrap(); + let mut opts = opts(Some(Provider::Supabase)); + opts.command = vec!["--env-file".into(), env_path.to_string_lossy().into_owned()]; + + let public_url = resolve_engine_public_url(&opts).unwrap(); + + assert_eq!( + public_url, + Some("http://host.docker.internal:6420".to_string()) + ); + } +} diff --git a/engine/packages/cli/src/commands/engine.rs b/engine/packages/cli/src/commands/engine.rs new file mode 100644 index 0000000000..8dd389b09e --- /dev/null +++ b/engine/packages/cli/src/commands/engine.rs @@ -0,0 +1,46 @@ +use std::{path::PathBuf, process::Stdio}; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use rivetkit_engine_process::{engine_env, resolve_engine_binary_path}; +use tokio::process::Command; + +use crate::engine_runner::engine_config; + +#[derive(Parser)] +pub struct Opts { + /// Path to a rivet-engine binary. Defaults to RIVET_ENGINE_BINARY_PATH, a + /// binary next to this CLI, a local build, or an auto-downloaded release. + #[arg(long)] + engine_binary: Option, + /// Arguments forwarded verbatim to the rivet-engine binary. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, +} + +impl Opts { + pub async fn execute(self) -> Result<()> { + let config = engine_config(self.engine_binary); + let binary = resolve_engine_binary_path(&config).await?; + let env = engine_env(&config)?; + + let mut command = Command::new(&binary); + command.args(&self.args); + for (key, value) in &env { + command.env(key, value); + } + command + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + let status = command + .status() + .await + .with_context(|| format!("run {}", binary.display()))?; + if !status.success() { + bail!("rivet-engine exited with {status}"); + } + Ok(()) + } +} diff --git a/engine/packages/cli/src/commands/mod.rs b/engine/packages/cli/src/commands/mod.rs new file mode 100644 index 0000000000..5321f220ba --- /dev/null +++ b/engine/packages/cli/src/commands/mod.rs @@ -0,0 +1,4 @@ +pub mod deploy; +pub mod dev; +pub mod engine; +pub mod setup_ci; diff --git a/engine/packages/cli/src/commands/setup_ci.rs b/engine/packages/cli/src/commands/setup_ci.rs new file mode 100644 index 0000000000..5658eb57f2 --- /dev/null +++ b/engine/packages/cli/src/commands/setup_ci.rs @@ -0,0 +1,33 @@ +use std::{fs, path::PathBuf}; + +use anyhow::{Result, bail}; +use clap::Parser; + +use crate::templates::{RIVET_DEPLOY_WORKFLOW_PATH, rivet_deploy_workflow}; + +#[derive(Parser)] +pub struct Opts { + /// Overwrite the workflow file if it already exists. + #[arg(long)] + force: bool, +} + +impl Opts { + pub async fn execute(self) -> Result<()> { + let path = PathBuf::from(RIVET_DEPLOY_WORKFLOW_PATH); + if path.exists() && !self.force { + bail!( + "{} already exists; pass --force to overwrite", + path.display() + ); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, rivet_deploy_workflow())?; + tracing::info!(path = %path.display(), "wrote GitHub Actions deploy workflow"); + tracing::info!("add your Rivet Cloud token as a repository secret to enable CI:"); + tracing::info!(" gh secret set RIVET_CLOUD_TOKEN"); + Ok(()) + } +} diff --git a/engine/packages/cli/src/credentials.rs b/engine/packages/cli/src/credentials.rs new file mode 100644 index 0000000000..8b5fcc512b --- /dev/null +++ b/engine/packages/cli/src/credentials.rs @@ -0,0 +1,78 @@ +use std::{env, fs, path::PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct Credentials { + pub rivet_cloud_token: String, +} + +/// Resolves the Rivet Cloud token from, in order: the flag, the +/// `RIVET_CLOUD_TOKEN` env var, then `~/.rivet/credentials`. +pub fn resolve_token(flag: Option<&str>) -> Result { + if let Some(token) = flag { + return Ok(token.to_string()); + } + if let Ok(token) = env::var("RIVET_CLOUD_TOKEN") { + if !token.trim().is_empty() { + return Ok(token); + } + } + let path = credentials_path()?; + if path.exists() { + let credentials: Credentials = serde_json::from_str(&fs::read_to_string(&path)?)?; + if !credentials.rivet_cloud_token.trim().is_empty() { + return Ok(credentials.rivet_cloud_token); + } + } + bail!("missing Rivet Cloud token; pass --token or set RIVET_CLOUD_TOKEN") +} + +pub fn write_credentials(token: &str) -> Result<()> { + let path = credentials_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let contents = serde_json::to_string_pretty(&Credentials { + rivet_cloud_token: token.to_string(), + })?; + write_secret_file(&path, contents.as_bytes())?; + Ok(()) +} + +#[cfg(unix)] +fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> Result<()> { + use std::{ + fs::OpenOptions, + io::Write, + os::unix::fs::{OpenOptionsExt, PermissionsExt}, + }; + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(path) + .with_context(|| format!("open {}", path.display()))?; + file.write_all(contents)?; + file.sync_all()?; + let mut perms = file.metadata()?.permissions(); + perms.set_mode(0o600); + fs::set_permissions(path, perms)?; + Ok(()) +} + +#[cfg(not(unix))] +fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> Result<()> { + fs::write(path, contents)?; + Ok(()) +} + +fn credentials_path() -> Result { + Ok(dirs::home_dir() + .context("could not resolve home directory")? + .join(".rivet") + .join("credentials")) +} diff --git a/engine/packages/cli/src/engine_runner.rs b/engine/packages/cli/src/engine_runner.rs new file mode 100644 index 0000000000..508f05d5e3 --- /dev/null +++ b/engine/packages/cli/src/engine_runner.rs @@ -0,0 +1,50 @@ +use std::{ + env, + path::{Path, PathBuf}, +}; + +use rivetkit_engine_process::EngineResolverConfig; + +use crate::DEFAULT_ENGINE_ENDPOINT; + +/// Builds the engine resolver config shared by `rivet dev` and `rivet engine`. +/// +/// Resolution order (handled by the engine-process crate): the explicit +/// `--engine-binary` path, then `RIVET_ENGINE_BINARY_PATH`, then a binary +/// bundled next to this CLI, then a local build, then an auto-downloaded +/// release. +pub fn engine_config(engine_binary: Option) -> EngineResolverConfig { + let explicit = engine_binary.or_else(|| { + let bundled = bundled_engine_binary(); + bundled.exists().then_some(bundled) + }); + + EngineResolverConfig::from_parts( + DEFAULT_ENGINE_ENDPOINT, + explicit, + None, + None, + engine_auto_download(), + ) +} + +/// Whether the CLI may download a release engine binary when none is found +/// locally. Enabled by default for the CLI; set `RIVETKIT_ENGINE_AUTO_DOWNLOAD` +/// to `0` or `false` to require a local binary. +fn engine_auto_download() -> bool { + match env::var("RIVETKIT_ENGINE_AUTO_DOWNLOAD") { + Ok(value) => !matches!(value.trim(), "0" | "false" | ""), + Err(_) => true, + } +} + +/// Path to a rivet-engine binary distributed next to this CLI binary. +fn bundled_engine_binary() -> PathBuf { + let exe = env::current_exe().unwrap_or_else(|_| PathBuf::from("rivet")); + let name = if cfg!(windows) { + "rivet-engine.exe" + } else { + "rivet-engine" + }; + exe.parent().unwrap_or_else(|| Path::new(".")).join(name) +} diff --git a/engine/packages/cli/src/main.rs b/engine/packages/cli/src/main.rs new file mode 100644 index 0000000000..0dd3c344f8 --- /dev/null +++ b/engine/packages/cli/src/main.rs @@ -0,0 +1,60 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; + +mod cloud; +mod commands; +mod credentials; +mod engine_runner; +mod templates; +mod util; + +pub(crate) const DEFAULT_CLOUD_API: &str = "https://cloud-api.rivet.dev"; +pub(crate) const DEFAULT_ENGINE_ENDPOINT: &str = "http://127.0.0.1:6420"; +pub(crate) const DEFAULT_NAMESPACE: &str = "production"; +pub(crate) const LOCAL_NAMESPACE: &str = "default"; +pub(crate) const POOL_NAME: &str = "default"; +pub(crate) const SUPABASE_FN_DEFAULT: &str = "rivet"; + +#[derive(Parser)] +#[command(name = "rivet", version, about = "Rivet CLI")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Run a local Rivet engine and the dev server for your handler. + Dev(commands::dev::Opts), + /// Run the bundled rivet-engine binary directly (proxies all arguments). + Engine(commands::engine::Opts), + /// Build and deploy the current project to Rivet Cloud. + Deploy(commands::deploy::Opts), + /// Install the GitHub Actions workflow that deploys to Rivet Cloud. + SetupCi(commands::setup_ci::Opts), +} + +#[tokio::main] +async fn main() -> Result<()> { + init_tracing(); + + let cli = Cli::parse(); + match cli.command { + Commands::Dev(opts) => opts.execute().await, + Commands::Engine(opts) => opts.execute().await, + Commands::Deploy(opts) => opts.execute().await, + Commands::SetupCi(opts) => opts.execute().await, + } +} + +fn init_tracing() { + use tracing_subscriber::{EnvFilter, fmt}; + + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .without_time() + .with_target(false) + .init(); +} diff --git a/engine/packages/cli/src/templates.rs b/engine/packages/cli/src/templates.rs new file mode 100644 index 0000000000..aafeb869f8 --- /dev/null +++ b/engine/packages/cli/src/templates.rs @@ -0,0 +1,33 @@ +/// Path of the GitHub Actions workflow installed by `rivet setup-ci`. +pub const RIVET_DEPLOY_WORKFLOW_PATH: &str = ".github/workflows/rivet-deploy.yml"; + +/// GitHub Actions workflow that deploys to Rivet Cloud on push and pull +/// request. Kept in sync with the dashboard cloud onboarding flow +/// (`frontend/src/app/getting-started.tsx`). +pub fn rivet_deploy_workflow() -> &'static str { + r#"name: Rivet Deploy + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: rivet-deploy-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + rivet-deploy: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: rivet-dev/deploy-action@v1.1.2 + with: + rivet-token: ${{ secrets.RIVET_CLOUD_TOKEN }} +"# +} diff --git a/engine/packages/cli/src/util.rs b/engine/packages/cli/src/util.rs new file mode 100644 index 0000000000..cc029339d5 --- /dev/null +++ b/engine/packages/cli/src/util.rs @@ -0,0 +1,109 @@ +use std::{ + collections::BTreeMap, + io::Write, + path::Path, + process::{Command as StdCommand, Stdio}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Context, Result, bail}; + +/// URL-encodes a path or query segment. +pub fn encode(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +/// Parses repeated `KEY=VAL` arguments into a map. +pub fn parse_env_vars(vars: &[String]) -> Result> { + let mut map = BTreeMap::new(); + for var in vars { + let Some((key, value)) = var.split_once('=') else { + bail!("--env must be KEY=VAL, got {var}"); + }; + if key.is_empty() { + bail!("--env key cannot be empty"); + } + map.insert(key.to_string(), value.to_string()); + } + Ok(map) +} + +/// Default image tag: the current git short SHA, or a unix timestamp outside a +/// git repo. +pub fn default_image_tag() -> String { + if let Ok(output) = StdCommand::new("git") + .args(["rev-parse", "--short=7", "HEAD"]) + .stderr(Stdio::null()) + .output() + { + if output.status.success() { + let tag = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !tag.is_empty() { + return tag; + } + } + } + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_string() +} + +pub fn docker_login(registry: &str, token: &str) -> Result<()> { + let mut child = StdCommand::new("docker") + .args(["login", registry, "--username", "rivet", "--password-stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .context("docker login")?; + child + .stdin + .as_mut() + .context("docker login stdin unavailable")? + .write_all(token.as_bytes())?; + let status = child.wait()?; + if !status.success() { + bail!("docker login failed with {status}"); + } + Ok(()) +} + +pub fn docker_build(context: &Path, dockerfile: &Path, image_ref: &str) -> Result<()> { + let context_str = context.to_string_lossy(); + let dockerfile_str = dockerfile.to_string_lossy(); + run_command( + "docker", + &[ + "buildx", + "build", + "--platform", + "linux/amd64", + "--load", + &context_str, + "-f", + &dockerfile_str, + "-t", + image_ref, + ], + None, + ) +} + +pub fn run_command(program: &str, args: &[&str], cwd: Option<&Path>) -> Result<()> { + tracing::info!(command = %format!("{} {}", program, args.join(" ")), "running command"); + let mut command = StdCommand::new(program); + command + .args(args) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + let status = command.status().with_context(|| format!("run {program}"))?; + if !status.success() { + bail!("{program} failed with {status}"); + } + Ok(()) +} diff --git a/engine/packages/pegboard-outbound/src/lib.rs b/engine/packages/pegboard-outbound/src/lib.rs index b38617d4bd..5d3f3a325d 100644 --- a/engine/packages/pegboard-outbound/src/lib.rs +++ b/engine/packages/pegboard-outbound/src/lib.rs @@ -238,23 +238,27 @@ async fn handle(ctx: &StandaloneCtx, packet: protocol::ToOutbound) -> Result<()> let protocol_version = pool.protocol_version.unwrap_or(PROTOCOL_VERSION); let res = async { - let payload = versioned::ToEnvoy::wrap_latest(protocol::ToEnvoy::ToEnvoyCommands(vec![ - protocol::CommandWrapper { - checkpoint, - inner: protocol::Command::CommandStartActor(protocol::CommandStartActor { - config: actor_config, - hibernating_requests: hibernating_requests - .into_iter() - .map(|x| protocol::HibernatingRequest { - gateway_id: x.gateway_id, - request_id: x.request_id, - }) - .collect(), - preloaded_kv, - }), - }, - ])) - .serialize_with_embedded_version(protocol_version)?; + let payload_body = + versioned::ToEnvoy::wrap_latest(protocol::ToEnvoy::ToEnvoyCommands(vec![ + protocol::CommandWrapper { + checkpoint, + inner: protocol::Command::CommandStartActor(protocol::CommandStartActor { + config: actor_config, + hibernating_requests: hibernating_requests + .into_iter() + .map(|x| protocol::HibernatingRequest { + gateway_id: x.gateway_id, + request_id: x.request_id, + }) + .collect(), + preloaded_kv, + }), + }, + ])) + .serialize(protocol_version)?; + let mut payload = Vec::with_capacity(2 + payload_body.len()); + payload.extend_from_slice(&protocol_version.to_le_bytes()); + payload.extend_from_slice(&payload_body); // Send ack to actor wf before starting an outbound req ctx.signal(pegboard::workflows::actor2::Allocated { generation }) diff --git a/engine/packages/pegboard/src/ops/runner_config/upsert.rs b/engine/packages/pegboard/src/ops/runner_config/upsert.rs index 186c1a4ce5..e5d2c3c4b3 100644 --- a/engine/packages/pegboard/src/ops/runner_config/upsert.rs +++ b/engine/packages/pegboard/src/ops/runner_config/upsert.rs @@ -204,6 +204,28 @@ pub async fn pegboard_runner_config_upsert(ctx: &OperationCtx, input: &Input) -> .custom_instrument(tracing::info_span!("runner_config_upsert_tx")) .await?; + if endpoint_config_changed { + crate::utils::purge_runner_config_caches(ctx.cache(), input.namespace_id, &input.name) + .await?; + + // Update runner metadata before notifying the pool workflow so newer + // RivetKit serverless handlers are treated as envoy-backed immediately. + if let Some((url, headers)) = serverless_config { + tracing::debug!("endpoint config changed, refreshing metadata"); + if let Err(err) = ctx + .op(crate::ops::runner_config::refresh_metadata::Input { + namespace_id: input.namespace_id, + runner_name: input.name.clone(), + url, + headers, + }) + .await + { + tracing::warn!(?err, runner_name=?input.name, "failed to refresh runner config metadata"); + } + } + } + if pool_created { ctx.workflow(crate::workflows::runner_pool::Input { namespace_id: input.namespace_id, @@ -240,28 +262,5 @@ pub async fn pegboard_runner_config_upsert(ctx: &OperationCtx, input: &Input) -> } } - if endpoint_config_changed { - crate::utils::purge_runner_config_caches(ctx.cache(), input.namespace_id, &input.name) - .await?; - - // Update runner metadata - // - // This allows us to populate the actor names immediately upon configuring a serverless runner - if let Some((url, headers)) = serverless_config { - tracing::debug!("endpoint config changed, refreshing metadata"); - if let Err(err) = ctx - .op(crate::ops::runner_config::refresh_metadata::Input { - namespace_id: input.namespace_id, - runner_name: input.name.clone(), - url, - headers, - }) - .await - { - tracing::warn!(?err, runner_name=?input.name, "failed to refresh runner config metadata"); - } - } - } - Ok(endpoint_config_changed) } diff --git a/examples/hello-world-cloudflare-workers/.gitignore b/examples/hello-world-cloudflare-workers/.gitignore new file mode 100644 index 0000000000..ad69be8c88 --- /dev/null +++ b/examples/hello-world-cloudflare-workers/.gitignore @@ -0,0 +1,4 @@ +.actorcore +node_modules +.wrangler +dist diff --git a/examples/hello-world-cloudflare-workers/README.md b/examples/hello-world-cloudflare-workers/README.md new file mode 100644 index 0000000000..851a7bced6 --- /dev/null +++ b/examples/hello-world-cloudflare-workers/README.md @@ -0,0 +1,29 @@ +# Hello World - Cloudflare Workers + +A minimal Rivet Actor counter running on Cloudflare Workers with the WebAssembly runtime. + +## Getting Started + +```sh +git clone https://github.com/rivet-dev/rivet.git +cd rivet/examples/hello-world-cloudflare-workers +npm install +npm run dev +``` + +`rivet dev` runs a local Rivet engine and spawns `wrangler dev` for you. + +## Implementation + +The Worker creates the registry with `runtime: "wasm"` and serves the Rivet handler. `RIVET_ENDPOINT` is the only required variable, set in [`wrangler.toml`](https://github.com/rivet-dev/rivet/tree/main/examples/hello-world-cloudflare-workers/wrangler.toml). + +- Worker entry ([`src/index.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/hello-world-cloudflare-workers/src/index.ts)): Counter actor and the `fetch` handler. +- WebSocket shim ([`src/cloudflare-websocket.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/hello-world-cloudflare-workers/src/cloudflare-websocket.ts)): Provides the `WebSocket` constructor the envoy connection needs on Workers. + +## Resources + +Read more about [actions](/docs/actors/actions) and [state](/docs/actors/state), or follow the [Cloudflare Workers Quickstart](/docs/actors/quickstart/cloudflare) and [deploy guide](/docs/deploy/cloudflare). + +## License + +MIT diff --git a/examples/hello-world-cloudflare-workers/package.json b/examples/hello-world-cloudflare-workers/package.json new file mode 100644 index 0000000000..c33d7fb8b5 --- /dev/null +++ b/examples/hello-world-cloudflare-workers/package.json @@ -0,0 +1,27 @@ +{ + "name": "hello-world-cloudflare-workers", + "version": "2.0.21", + "private": true, + "type": "module", + "scripts": { + "dev": "npx @rivetkit/cli dev --provider cloudflare", + "check-types": "tsc --noEmit", + "deploy": "wrangler deploy" + }, + "devDependencies": { + "typescript": "^5.7.3", + "wrangler": "^4.87.0" + }, + "dependencies": { + "@rivetkit/rivetkit-wasm": "*", + "rivetkit": "*" + }, + "stableVersion": "0.8.0", + "template": { + "technologies": ["rivet", "cloudflare", "typescript"], + "tags": ["serverless"], + "noFrontend": true, + "skipVercel": true + }, + "license": "MIT" +} diff --git a/examples/hello-world-cloudflare-workers/src/cloudflare-websocket.ts b/examples/hello-world-cloudflare-workers/src/cloudflare-websocket.ts new file mode 100644 index 0000000000..1a52edba21 --- /dev/null +++ b/examples/hello-world-cloudflare-workers/src/cloudflare-websocket.ts @@ -0,0 +1,90 @@ +// Cloudflare Workers do not expose the `new WebSocket()` constructor that the +// Rivet envoy client uses to reach the engine. This shim implements that +// constructor on top of the fetch-based WebSocket upgrade that Workers support. + +type CloudflareWebSocket = WebSocket & { accept(): void }; + +class FetchWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + binaryType: BinaryType = "arraybuffer"; + onopen: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + readyState = FetchWebSocket.CONNECTING; + #socket: CloudflareWebSocket | undefined; + #pending: Array = []; + + constructor(url: string, protocols?: string | string[]) { + void this.#connect(url, protocols); + } + + async #connect(url: string, protocols?: string | string[]) { + try { + const protocolList = Array.isArray(protocols) + ? protocols + : protocols + ? [protocols] + : []; + const headers = new Headers({ Upgrade: "websocket" }); + if (protocolList.length > 0) { + headers.set("Sec-WebSocket-Protocol", protocolList.join(", ")); + } + const response = await fetch( + url.replace(/^ws:/, "http:").replace(/^wss:/, "https:"), + { headers }, + ); + const socket = ( + response as unknown as { webSocket: CloudflareWebSocket | null } + ).webSocket; + if (!socket) { + throw new Error( + `websocket upgrade failed with status ${response.status}`, + ); + } + + socket.accept(); + socket.binaryType = this.binaryType; + this.#socket = socket; + this.readyState = FetchWebSocket.OPEN; + socket.addEventListener("message", (event) => { + this.onmessage?.(event); + }); + socket.addEventListener("close", (event) => { + this.readyState = FetchWebSocket.CLOSED; + this.onclose?.(event); + }); + socket.addEventListener("error", () => { + this.onerror?.(new Event("error")); + }); + this.onopen?.(new Event("open")); + for (const data of this.#pending.splice(0)) { + socket.send(data); + } + } catch { + this.readyState = FetchWebSocket.CLOSED; + this.onerror?.(new Event("error")); + this.onclose?.(new CloseEvent("close", { code: 1006 })); + } + } + + send(data: string | ArrayBuffer | ArrayBufferView) { + if (this.readyState === FetchWebSocket.CONNECTING) { + this.#pending.push(data); + return; + } + this.#socket?.send(data); + } + + close(code?: number, reason?: string) { + this.readyState = FetchWebSocket.CLOSING; + this.#socket?.close(code, reason); + } +} + +(globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket = + FetchWebSocket as unknown as typeof WebSocket; diff --git a/examples/hello-world-cloudflare-workers/src/index.ts b/examples/hello-world-cloudflare-workers/src/index.ts new file mode 100644 index 0000000000..9ae7c67dfd --- /dev/null +++ b/examples/hello-world-cloudflare-workers/src/index.ts @@ -0,0 +1,38 @@ +import * as wasmBindings from "@rivetkit/rivetkit-wasm"; +import wasmModule from "@rivetkit/rivetkit-wasm/rivetkit_wasm_bg.wasm"; +import { actor, setup } from "rivetkit"; +import "./cloudflare-websocket"; + +const counter = actor({ + state: { count: 0 }, + actions: { + increment: (c, amount = 1) => { + c.state.count += amount; + return c.state.count; + }, + getCount: (c) => c.state.count, + }, +}); + +interface Env { + RIVET_ENDPOINT: string; +} + +let registry: { handler(request: Request): Promise } | undefined; + +function getRegistry(env: Env) { + registry ??= setup({ + runtime: "wasm", + wasm: { bindings: wasmBindings, initInput: wasmModule }, + use: { counter }, + endpoint: env.RIVET_ENDPOINT, + }); + + return registry; +} + +export default { + async fetch(request: Request, env: Env): Promise { + return await getRegistry(env).handler(request); + }, +}; diff --git a/examples/hello-world-cloudflare-workers/src/wasm.d.ts b/examples/hello-world-cloudflare-workers/src/wasm.d.ts new file mode 100644 index 0000000000..7f14d1a5a5 --- /dev/null +++ b/examples/hello-world-cloudflare-workers/src/wasm.d.ts @@ -0,0 +1,4 @@ +declare module "*.wasm" { + const wasmModule: WebAssembly.Module; + export default wasmModule; +} diff --git a/examples/hello-world-cloudflare-workers/tsconfig.json b/examples/hello-world-cloudflare-workers/tsconfig.json new file mode 100644 index 0000000000..74817ce07f --- /dev/null +++ b/examples/hello-world-cloudflare-workers/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext", "dom", "dom.iterable"], + "module": "esnext", + "moduleResolution": "bundler", + "types": [], + "noEmit": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/examples/hello-world-cloudflare-workers/turbo.json b/examples/hello-world-cloudflare-workers/turbo.json new file mode 100644 index 0000000000..8d06db6c15 --- /dev/null +++ b/examples/hello-world-cloudflare-workers/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["rivetkit#build"] + } + } +} diff --git a/examples/hello-world-cloudflare-workers/wrangler.toml b/examples/hello-world-cloudflare-workers/wrangler.toml new file mode 100644 index 0000000000..88d3cf4362 --- /dev/null +++ b/examples/hello-world-cloudflare-workers/wrangler.toml @@ -0,0 +1,7 @@ +name = "hello-world-cloudflare-workers" +main = "src/index.ts" +compatibility_date = "2025-04-01" +compatibility_flags = ["nodejs_compat"] + +[vars] +RIVET_ENDPOINT = "http://localhost:6420" diff --git a/examples/hello-world-supabase-functions/.gitignore b/examples/hello-world-supabase-functions/.gitignore new file mode 100644 index 0000000000..fd1591ff01 --- /dev/null +++ b/examples/hello-world-supabase-functions/.gitignore @@ -0,0 +1,4 @@ +.actorcore +node_modules +supabase/.temp +supabase/.branches diff --git a/examples/hello-world-supabase-functions/README.md b/examples/hello-world-supabase-functions/README.md new file mode 100644 index 0000000000..c1903f94f3 --- /dev/null +++ b/examples/hello-world-supabase-functions/README.md @@ -0,0 +1,33 @@ +# Hello World - Supabase Functions + +A minimal Rivet Actor counter running on Supabase Edge Functions with the WebAssembly runtime. + +## Getting Started + +```sh +git clone https://github.com/rivet-dev/rivet.git +cd rivet/examples/hello-world-supabase-functions +npm install +npm run dev +``` + +`rivet dev` runs a local Rivet engine and spawns `supabase functions serve` for you. + +## Prerequisites + +- [Supabase CLI](https://supabase.com/docs/guides/cli) +- Docker, for Supabase's local Edge Runtime + +## Implementation + +The function loads the wasm bytes with Deno, creates the registry with `runtime: "wasm"`, and serves the Rivet handler. `RIVET_ENDPOINT` is the only required variable. + +See [`supabase/functions/rivet/index.ts`](https://github.com/rivet-dev/rivet/tree/main/examples/hello-world-supabase-functions/supabase/functions/rivet/index.ts). + +## Resources + +Read more about [actions](/docs/actors/actions) and [state](/docs/actors/state), or follow the [Supabase Functions Quickstart](/docs/actors/quickstart/supabase) and [deploy guide](/docs/deploy/supabase). + +## License + +MIT diff --git a/examples/hello-world-supabase-functions/package.json b/examples/hello-world-supabase-functions/package.json new file mode 100644 index 0000000000..27f7831076 --- /dev/null +++ b/examples/hello-world-supabase-functions/package.json @@ -0,0 +1,26 @@ +{ + "name": "hello-world-supabase-functions", + "version": "2.0.21", + "private": true, + "type": "module", + "scripts": { + "dev": "npx @rivetkit/cli dev --provider supabase", + "check-types": "tsc --noEmit", + "deploy": "npx supabase functions deploy rivet" + }, + "devDependencies": { + "typescript": "^5.7.3" + }, + "dependencies": { + "@rivetkit/rivetkit-wasm": "*", + "rivetkit": "*" + }, + "stableVersion": "0.8.0", + "template": { + "technologies": ["rivet", "supabase", "typescript"], + "tags": ["serverless"], + "noFrontend": true, + "skipVercel": true + }, + "license": "MIT" +} diff --git a/examples/hello-world-supabase-functions/supabase/config.toml b/examples/hello-world-supabase-functions/supabase/config.toml new file mode 100644 index 0000000000..ac1794f435 --- /dev/null +++ b/examples/hello-world-supabase-functions/supabase/config.toml @@ -0,0 +1,4 @@ +project_id = "hello-world-rivet" + +[edge_runtime] +enabled = true diff --git a/examples/hello-world-supabase-functions/supabase/functions/rivet/deno.d.ts b/examples/hello-world-supabase-functions/supabase/functions/rivet/deno.d.ts new file mode 100644 index 0000000000..336df806ad --- /dev/null +++ b/examples/hello-world-supabase-functions/supabase/functions/rivet/deno.d.ts @@ -0,0 +1,5 @@ +declare const Deno: { + readFile(path: string | URL): Promise; + env: { get(key: string): string | undefined }; + serve(handler: (request: Request) => Response | Promise): void; +}; diff --git a/examples/hello-world-supabase-functions/supabase/functions/rivet/index.ts b/examples/hello-world-supabase-functions/supabase/functions/rivet/index.ts new file mode 100644 index 0000000000..d43bb36c28 --- /dev/null +++ b/examples/hello-world-supabase-functions/supabase/functions/rivet/index.ts @@ -0,0 +1,29 @@ +import * as wasmBindings from "@rivetkit/rivetkit-wasm"; +import { actor, setup } from "rivetkit"; + +const resolveModule = ( + import.meta as unknown as { resolve(specifier: string): string } +).resolve; +const wasmModule = await Deno.readFile( + new URL(resolveModule("@rivetkit/rivetkit-wasm/rivetkit_wasm_bg.wasm")), +); + +const counter = actor({ + state: { count: 0 }, + actions: { + increment: (c, amount = 1) => { + c.state.count += amount; + return c.state.count; + }, + getCount: (c) => c.state.count, + }, +}); + +const registry = setup({ + runtime: "wasm", + wasm: { bindings: wasmBindings, initInput: wasmModule }, + use: { counter }, + endpoint: Deno.env.get("RIVET_ENDPOINT"), +}); + +Deno.serve((request) => registry.handler(request)); diff --git a/examples/hello-world-supabase-functions/tsconfig.json b/examples/hello-world-supabase-functions/tsconfig.json new file mode 100644 index 0000000000..74a2f5749b --- /dev/null +++ b/examples/hello-world-supabase-functions/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext", "dom"], + "module": "esnext", + "moduleResolution": "bundler", + "types": [], + "noEmit": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["supabase/functions/**/*"] +} diff --git a/examples/hello-world-supabase-functions/turbo.json b/examples/hello-world-supabase-functions/turbo.json new file mode 100644 index 0000000000..8d06db6c15 --- /dev/null +++ b/examples/hello-world-supabase-functions/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["rivetkit#build"] + } + } +} diff --git a/frontend/packages/icons/CLAUDE.md b/frontend/packages/icons/CLAUDE.md index eac30018b4..192e568115 100644 --- a/frontend/packages/icons/CLAUDE.md +++ b/frontend/packages/icons/CLAUDE.md @@ -4,6 +4,14 @@ Icons come from Font Awesome Pro packages and a custom Font Awesome kit (`@awesome.me/kit-63db24046b`). +## Requesting a new icon + +Custom kit icons (for example a company brand) cannot be uploaded by an agent. When you need one: + +1. If you can find the relevant icon (for example a company logo), look up its SVG and download a single-color (monocolor, `fill="currentColor"`) version to a temp directory ready for the user to upload. Do not commit it to the repo. +2. Ask the user to upload it to the custom Font Awesome kit at https://fontawesome.com/kits/63db24046b/customicons. +3. Once the user confirms it is uploaded, repull the icons with the steps below, then consume the generated icon from `@rivet-gg/icons` instead of inlining an SVG path. + ## Adding new custom kit icons When a new icon has been uploaded to the custom Font Awesome kit: diff --git a/frontend/packages/icons/dist/icons/faSupabase.js b/frontend/packages/icons/dist/icons/faSupabase.js new file mode 100644 index 0000000000..7b900ab5c8 --- /dev/null +++ b/frontend/packages/icons/dist/icons/faSupabase.js @@ -0,0 +1,15 @@ +// src/node_modules/@awesome.me/kit-63db24046b/icons/modules/kit/custom.mjs +var faSupabase = { + prefix: "fak", + iconName: "supabase", + icon: [ + 512, + 512, + [], + "e01c", + "M253.9 22.1c-.3-21-26.9-30.1-40-13.6L16.3 257.1c-23.3 29.4-2.4 72.6 35.1 72.6l204.4 0 2.4 160.2c.3 21 26.9 30 40 13.6L495.7 254.9c23.3-29.3 2.4-72.6-35.1-72.6l-205.7 0z" + ] +}; +export { + faSupabase +}; diff --git a/frontend/packages/icons/dist/index.js b/frontend/packages/icons/dist/index.js index 22f26b7a06..94f0eed9b6 100644 --- a/frontend/packages/icons/dist/index.js +++ b/frontend/packages/icons/dist/index.js @@ -4928,6 +4928,7 @@ export { faRender } from "./icons/faRender.js"; export { faRivet } from "./icons/faRivet.js"; export { faSelect } from "./icons/faSelect.js"; export { faSqlite } from "./icons/faSqlite.js"; +export { faSupabase } from "./icons/faSupabase.js"; export { faTs } from "./icons/faTs.js"; export { faVercel } from "./icons/faVercel.js"; export { faVscode } from "./icons/faVscode.js"; diff --git a/frontend/packages/icons/manifest.json b/frontend/packages/icons/manifest.json index 4dce48b067..a7df7de785 100644 --- a/frontend/packages/icons/manifest.json +++ b/frontend/packages/icons/manifest.json @@ -24487,6 +24487,12 @@ "faSqlite" ] }, + { + "icon": "faSupabase", + "aliases": [ + "faSupabase" + ] + }, { "icon": "faTs", "aliases": [ diff --git a/frontend/packages/icons/scripts/shared-utils.js b/frontend/packages/icons/scripts/shared-utils.js index 9bad3e34eb..8810d61221 100644 --- a/frontend/packages/icons/scripts/shared-utils.js +++ b/frontend/packages/icons/scripts/shared-utils.js @@ -37,7 +37,7 @@ const PATHS = { const FA_PACKAGES_CONFIG = { // Custom kit with Rivet-specific icons - "@awesome.me/kit-63db24046b": "1.0.43", + "@awesome.me/kit-63db24046b": "1.0.44", // Pro packages (regular and solid styles) "@fortawesome/pro-regular-svg-icons": "6.6.0", "@fortawesome/pro-solid-svg-icons": "6.6.0", diff --git a/frontend/packages/icons/src/index.gen.js b/frontend/packages/icons/src/index.gen.js index 1085ac37a2..3cfcd4f885 100644 --- a/frontend/packages/icons/src/index.gen.js +++ b/frontend/packages/icons/src/index.gen.js @@ -4921,4 +4921,4 @@ export { definition as faWreathLaurel } from "@fortawesome/pro-solid-svg-icons/f export { definition as faWrenchSimple } from "@fortawesome/pro-solid-svg-icons/faWrenchSimple"; export { definition as faXmarkLarge } from "@fortawesome/pro-solid-svg-icons/faXmarkLarge"; export const faHono = {"prefix":"fakd","iconName":"hono","icon":[512,512,[],"e012",["M62.5 299.3c-2.4 24.7-.1 48.8 7 72.5 27.4 72.7 79.3 116.8 155.5 132.4 63.8 9.1 120.2-7 169.1-48.3 55.9-54.1 70.2-117.7 42.8-190.8-17.2-41-38-80-62.4-116.8-33-48.9-68.3-96.2-105.7-141.9-1-.8-2.2-1.2-3.5-1-42.5 52.7-79.1 109.4-109.7 170.1-4-3.5-7.9-7.2-11.6-11.1-8-11-16.4-21.8-25.2-32.2-10.8 13.4-19.5 28.2-26.2 44.3-16.1 39.4-26.1 80.4-30.2 122.8zm65.4 22.1c-1.9-16.2-.2-32 5-47.3 7.5-19 16.5-37.1 27.2-54.4 10.1-14.8 20.1-29.5 30.2-44.3 22.9-29.4 45.5-58.9 68-88.6 36.9 42.8 70.3 88.5 100.2 136.9 9.4 16 17.1 32.8 23.2 50.3 12.7 49.8-.9 90.9-40.8 123.3-38.5 27.1-80.8 35.2-126.8 24.2-49.6-15.4-78.3-48.8-86.1-100.2z","M258.3 86.9c36.9 42.8 70.3 88.5 100.2 136.9 9.4 16 17.1 32.8 23.2 50.3 12.7 49.8-.9 90.9-40.8 123.3-38.5 27.1-80.8 35.2-126.8 24.2-49.6-15.4-78.3-48.8-86.1-100.2-1.9-16.2-.2-32 5-47.3 7.5-19 16.5-37.1 27.2-54.4 10.1-14.8 20.1-29.5 30.2-44.3 22.9-29.4 45.5-58.9 68-88.6z"]]}; -export { faActors, faActorsBorderless, faCursor, faFreestyle, faGb, faGoogleCloud, faHetzner, faHetznerH, faLinear, faLogs, faNetlify, faNextjs, faProject, faRailway, faRegex, faRender, faRivet, faSelect, faSqlite, faTs, faVercel, faVscode, faWord, faWorkflow, faWorkflowBorderless } from "@awesome.me/kit-63db24046b/icons/kit/custom"; +export { faActors, faActorsBorderless, faCursor, faFreestyle, faGb, faGoogleCloud, faHetzner, faHetznerH, faLinear, faLogs, faNetlify, faNextjs, faProject, faRailway, faRegex, faRender, faRivet, faSelect, faSqlite, faSupabase, faTs, faVercel, faVscode, faWord, faWorkflow, faWorkflowBorderless } from "@awesome.me/kit-63db24046b/icons/kit/custom"; diff --git a/frontend/packages/icons/src/index.gen.ts b/frontend/packages/icons/src/index.gen.ts index 421c95317a..a11bc8edc5 100644 --- a/frontend/packages/icons/src/index.gen.ts +++ b/frontend/packages/icons/src/index.gen.ts @@ -4922,4 +4922,4 @@ export { definition as faWreathLaurel } from "@fortawesome/pro-solid-svg-icons/f export { definition as faWrenchSimple } from "@fortawesome/pro-solid-svg-icons/faWrenchSimple"; export { definition as faXmarkLarge } from "@fortawesome/pro-solid-svg-icons/faXmarkLarge"; export const faHono = {"prefix":"fakd","iconName":"hono","icon":[512,512,[],"e012",["M62.5 299.3c-2.4 24.7-.1 48.8 7 72.5 27.4 72.7 79.3 116.8 155.5 132.4 63.8 9.1 120.2-7 169.1-48.3 55.9-54.1 70.2-117.7 42.8-190.8-17.2-41-38-80-62.4-116.8-33-48.9-68.3-96.2-105.7-141.9-1-.8-2.2-1.2-3.5-1-42.5 52.7-79.1 109.4-109.7 170.1-4-3.5-7.9-7.2-11.6-11.1-8-11-16.4-21.8-25.2-32.2-10.8 13.4-19.5 28.2-26.2 44.3-16.1 39.4-26.1 80.4-30.2 122.8zm65.4 22.1c-1.9-16.2-.2-32 5-47.3 7.5-19 16.5-37.1 27.2-54.4 10.1-14.8 20.1-29.5 30.2-44.3 22.9-29.4 45.5-58.9 68-88.6 36.9 42.8 70.3 88.5 100.2 136.9 9.4 16 17.1 32.8 23.2 50.3 12.7 49.8-.9 90.9-40.8 123.3-38.5 27.1-80.8 35.2-126.8 24.2-49.6-15.4-78.3-48.8-86.1-100.2z","M258.3 86.9c36.9 42.8 70.3 88.5 100.2 136.9 9.4 16 17.1 32.8 23.2 50.3 12.7 49.8-.9 90.9-40.8 123.3-38.5 27.1-80.8 35.2-126.8 24.2-49.6-15.4-78.3-48.8-86.1-100.2-1.9-16.2-.2-32 5-47.3 7.5-19 16.5-37.1 27.2-54.4 10.1-14.8 20.1-29.5 30.2-44.3 22.9-29.4 45.5-58.9 68-88.6z"]]}; -export { faActors, faActorsBorderless, faCursor, faFreestyle, faGb, faGoogleCloud, faHetzner, faHetznerH, faLinear, faLogs, faNetlify, faNextjs, faProject, faRailway, faRegex, faRender, faRivet, faSelect, faSqlite, faTs, faVercel, faVscode, faWord, faWorkflow, faWorkflowBorderless } from "@awesome.me/kit-63db24046b/icons/kit/custom"; +export { faActors, faActorsBorderless, faCursor, faFreestyle, faGb, faGoogleCloud, faHetzner, faHetznerH, faLinear, faLogs, faNetlify, faNextjs, faProject, faRailway, faRegex, faRender, faRivet, faSelect, faSqlite, faSupabase, faTs, faVercel, faVscode, faWord, faWorkflow, faWorkflowBorderless } from "@awesome.me/kit-63db24046b/icons/kit/custom"; diff --git a/frontend/packages/shared-data/src/deploy.ts b/frontend/packages/shared-data/src/deploy.ts index afd9adfc74..33f6f20129 100644 --- a/frontend/packages/shared-data/src/deploy.ts +++ b/frontend/packages/shared-data/src/deploy.ts @@ -8,24 +8,10 @@ import { faRivet, faRocket, faServer, + faSupabase, faVercel, } from "@rivet-gg/icons"; -// Supabase's official monotone logo. Font Awesome has no Supabase brand icon, -// so this is a Font Awesome compatible icon definition built from the real -// brand SVG (https://simpleicons.org/?q=supabase). Renders in currentColor. -export const faSupabase = { - prefix: "fak", - iconName: "supabase", - icon: [ - 24, - 24, - [], - "", - "M11.9 1.036c-.015-.986-1.26-1.41-1.874-.637L.764 12.05C-.33 13.427.65 15.455 2.409 15.455h9.579l.113 7.51c.014.985 1.259 1.408 1.873.636l9.262-11.653c1.093-1.375.113-3.403-1.645-3.403h-9.642z", - ], -} as any; - export interface DeployOption { displayName: string; name: string; diff --git a/frontend/src/app/getting-started.tsx b/frontend/src/app/getting-started.tsx index 0428b149b1..aa3e10c1b4 100644 --- a/frontend/src/app/getting-started.tsx +++ b/frontend/src/app/getting-started.tsx @@ -912,6 +912,9 @@ function BackendSetupRivet() { dataProvider.createApiTokenQueryOptions({ name: "Onboarding" }), ); + const deployCmd = cloudToken + ? `npx @rivetkit/cli deploy --token ${cloudToken}` + : "npx @rivetkit/cli deploy --token "; const ghSecretCmd = cloudToken ? `gh secret set RIVET_CLOUD_TOKEN --body "${cloudToken}"` : "gh secret set RIVET_CLOUD_TOKEN"; @@ -937,27 +940,24 @@ function BackendSetupRivet() {
-

Add GitHub secret

+

Deploy to Rivet Compute

- Add your Rivet token as a repository secret named{" "} - - RIVET_CLOUD_TOKEN - - . + Run the deploy command from your project root. The token + is saved locally for future deploys.

{[ ghSecretCmd} + code={() => deployCmd} className="m-0" > , ]} @@ -967,14 +967,31 @@ function BackendSetupRivet() {
-

Add GitHub Action

+

Optionally add CI

- Create{" "} + Add your token as a repository secret, then create{" "} .github/workflows/rivet-deploy.yml {" "} - to automatically deploy on every push and pull request. + to deploy on every push and pull request.

+ + {[ + ghSecretCmd} + className="m-0" + > + + , + ]} + {[
-

Deploy to Rivet Compute

+

Monitor deployment

- Push your changes to trigger the{" "} - Rivet Deploy workflow. The status check - below will update automatically once your backend is - deployed. + The status check below updates automatically once your + backend is deployed.

@@ -1300,7 +1315,7 @@ function FrontendSetup() { label="Deploy your backend" sublabel={ waitingForFirstImage - ? "Push your changes to trigger the Rivet Deploy workflow." + ? "Run npx @rivetkit/cli deploy from your project." : "Deployment detected." } /> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e6a4add9e..06072625c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -998,6 +998,22 @@ 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-cloudflare-workers: + dependencies: + '@rivetkit/rivetkit-wasm': + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/rivetkit-wasm + rivetkit: + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/rivetkit + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + wrangler: + specifier: ^4.87.0 + version: 4.100.0 + examples/hello-world-effect: dependencies: '@effect/platform-node': @@ -1087,6 +1103,19 @@ importers: specifier: ^5.0.0 version: 5.4.21(@types/node@22.19.15)(less@4.4.1)(lightningcss@1.32.0)(sass@1.93.2)(stylus@0.62.0)(terser@5.46.0) + examples/hello-world-supabase-functions: + dependencies: + '@rivetkit/rivetkit-wasm': + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/rivetkit-wasm + rivetkit: + specifier: workspace:* + version: link:../../rivetkit-typescript/packages/rivetkit + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + examples/hono: dependencies: hono: @@ -2839,6 +2868,8 @@ importers: specifier: ^5.7.3 version: 5.9.3 + rivetkit-typescript/packages/cli: {} + rivetkit-typescript/packages/devtools: dependencies: '@floating-ui/react': @@ -4736,6 +4767,49 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260611.1': + resolution: {integrity: sha512-iJICldmi4sBGgi7IrQles8cStOGXM/Tmv95C4OODVs6VIbMsJPqThUM5h3uYVQNULuJ8I/aVvnJ3Eh/wZCKwuA==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260611.1': + resolution: {integrity: sha512-yBbVXvbZyltR3I7NJdC4C4ItkItjZSiabcA/3HzEWOUQjLVKFqRh4so6ToHr70VCYh8VGeR8EDZL23igLhXqFQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260611.1': + resolution: {integrity: sha512-PfNjpxOlaIgZFYuhD7+neEEewCN2Ud993wEEN0fmbtSOax1AK53LGqmXUDvFhnbkHxJLFAxYCSNISW8QbzaAIg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260611.1': + resolution: {integrity: sha512-GEp4XbuIKjlF8pakqXcUDJfKiJosD/Q7S83J0d+r+z9XIlYGfF3ntm08e2aiF5TFTwp3fnG4yMoPUAKNhNJpvQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260611.1': + resolution: {integrity: sha512-S6JkS0kEbcCKs19RGqEPhjCRbP8GBkQwqYLp2fhBJtD/KTlwqLzOJ9E6PQ7gQKgWHtxy1NBG3oXarlNFRNU/dw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@cloudflare/workers-types@4.20251014.0': resolution: {integrity: sha512-tEW98J/kOa0TdylIUOrLKRdwkUw0rvvYVlo+Ce0mqRH3c8kSoxLzUH9gfCvwLe0M89z1RkzFovSKAW2Nwtyn3w==} @@ -6990,6 +7064,15 @@ packages: engines: {node: '>=18'} hasBin: true + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@posthog/core@1.5.3': resolution: {integrity: sha512-1cHCMR2uS/rAdBIFlBPJ4rPYaw1O42VkFy/LwQLtoy2hMQb2DdhCoSHfgA66R9TvcOybZsSANlbuihmGEZUKVQ==} @@ -8345,6 +8428,10 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + '@sindresorhus/merge-streams@2.3.0': resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} @@ -8559,6 +8646,9 @@ packages: resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} + '@speed-highlight/core@1.2.17': + resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -10203,6 +10293,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + bn.js@4.12.3: resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} @@ -11556,6 +11649,9 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} @@ -13105,6 +13201,10 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + koa-compose@4.1.0: resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} @@ -13892,6 +13992,11 @@ packages: resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} hasBin: true + miniflare@4.20260611.0: + resolution: {integrity: sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg==} + engines: {node: '>=22.0.0'} + hasBin: true + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -16035,6 +16140,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -16512,10 +16621,17 @@ packages: resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} engines: {node: '>=20.18.1'} + undici@7.24.8: + resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} + engines: {node: '>=20.18.1'} + undici@8.3.0: resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==} engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -17288,6 +17404,21 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + workerd@1.20260611.1: + resolution: {integrity: sha512-CS/640T7pIJ2HYX6x2DwKFGbcSckAWN3tgcdq+ptB6SaqjWUhlzIgA/YhPuwIU+/NnMnGpqOFX/hC18Oyge63w==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.100.0: + resolution: {integrity: sha512-dSQO7DO+mD6XDzkVWIWBoGLO3yw+lacWSc/KhFvd7pgfpth+kX98qb5SGRHZN8ACCDhhfwzDLXwB6qHsIHhfBg==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260611.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -17477,6 +17608,12 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + z-schema@5.0.5: resolution: {integrity: sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==} engines: {node: '>=8.0.0'} @@ -19033,6 +19170,29 @@ snapshots: '@chevrotain/utils@11.0.3': {} + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260611.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260611.1 + + '@cloudflare/workerd-darwin-64@1.20260611.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260611.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260611.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260611.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260611.1': + optional: true + '@cloudflare/workers-types@4.20251014.0': optional: true @@ -21670,6 +21830,18 @@ snapshots: playwright: 1.57.0 optional: true + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@posthog/core@1.5.3': dependencies: cross-spawn: 7.0.6 @@ -23374,6 +23546,8 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@sindresorhus/is@7.2.0': {} + '@sindresorhus/merge-streams@2.3.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -23696,6 +23870,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@speed-highlight/core@1.2.17': {} + '@standard-schema/spec@1.0.0': {} '@standard-schema/spec@1.1.0': {} @@ -25825,6 +26001,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + blake3-wasm@2.1.5: {} + bn.js@4.12.3: {} bn.js@5.2.3: {} @@ -27107,6 +27285,8 @@ snapshots: dependencies: is-arrayish: 0.2.1 + error-stack-parser-es@1.0.5: {} + error-stack-parser@2.1.4: dependencies: stackframe: 1.3.4 @@ -29056,6 +29236,8 @@ snapshots: kleur@3.0.3: {} + kleur@4.1.5: {} + koa-compose@4.1.0: {} koa-convert@2.0.0: @@ -30315,6 +30497,18 @@ snapshots: mini-svg-data-uri@1.4.4: {} + miniflare@4.20260611.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.24.8 + workerd: 1.20260611.1 + ws: 8.20.1 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimalistic-assert@1.0.1: {} minimalistic-crypto-utils@1.0.1: {} @@ -32830,6 +33024,8 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + supports-color@10.2.2: {} + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -33460,8 +33656,14 @@ snapshots: undici@7.24.7: {} + undici@7.24.8: {} + undici@8.3.0: {} + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-emoji-modifier-base@1.0.0: {} @@ -34606,6 +34808,30 @@ snapshots: wordwrap@1.0.0: {} + workerd@1.20260611.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260611.1 + '@cloudflare/workerd-darwin-arm64': 1.20260611.1 + '@cloudflare/workerd-linux-64': 1.20260611.1 + '@cloudflare/workerd-linux-arm64': 1.20260611.1 + '@cloudflare/workerd-windows-64': 1.20260611.1 + + wrangler@4.100.0: + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260611.1) + blake3-wasm: 2.1.5 + esbuild: 0.27.3 + miniflare: 4.20260611.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260611.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -34746,6 +34972,19 @@ snapshots: yoctocolors@2.1.2: {} + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.17 + cookie: 1.1.1 + youch-core: 0.3.3 + z-schema@5.0.5: dependencies: lodash.get: 4.4.2 diff --git a/rivetkit-rust/packages/engine-process/Cargo.toml b/rivetkit-rust/packages/engine-process/Cargo.toml new file mode 100644 index 0000000000..55f9a72f46 --- /dev/null +++ b/rivetkit-rust/packages/engine-process/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "rivetkit-engine-process" +version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +edition.workspace = true +workspace = "../../../" +description = "Resolves, spawns, and reuses the rivet-engine subprocess for local hosts and the CLI" + +[dependencies] +anyhow.workspace = true +reqwest.workspace = true +rivet-error.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tokio.workspace = true +tracing.workspace = true +url.workspace = true + +[dev-dependencies] +tempfile.workspace = true +tokio = { workspace = true, features = ["test-util"] } diff --git a/rivetkit-rust/packages/engine-process/src/error.rs b/rivetkit-rust/packages/engine-process/src/error.rs new file mode 100644 index 0000000000..956b2232ce --- /dev/null +++ b/rivetkit-rust/packages/engine-process/src/error.rs @@ -0,0 +1,62 @@ +use rivet_error::RivetError; +use serde::{Deserialize, Serialize}; + +#[derive(RivetError, Debug, Clone, Deserialize, Serialize)] +#[error("engine")] +pub enum EngineProcessError { + #[error( + "binary_not_found", + "Engine binary was not found.", + "Engine binary was not found at '{path}'." + )] + BinaryNotFound { path: String }, + + #[error( + "binary_unavailable", + "Engine binary is unavailable.", + "No usable engine binary was found for version '{version}'. Build `rivet-engine`, set `RIVET_ENGINE_BINARY_PATH`, or enable `RIVETKIT_ENGINE_AUTO_DOWNLOAD=1`." + )] + BinaryUnavailable { version: String }, + + #[error( + "download_failed", + "Engine binary download failed.", + "Engine binary download failed for '{url}': {reason}" + )] + DownloadFailed { url: String, reason: String }, + + #[error( + "checksum_mismatch", + "Engine binary checksum mismatch.", + "Engine binary checksum mismatch for '{artifact}': expected {expected}, received {received}." + )] + ChecksumMismatch { + artifact: String, + expected: String, + received: String, + }, + + #[error( + "invalid_endpoint", + "Engine endpoint is invalid.", + "Engine endpoint '{endpoint}' is invalid: {reason}" + )] + InvalidEndpoint { endpoint: String, reason: String }, + + #[error("missing_pid", "Engine process is missing a pid.")] + MissingPid, + + #[error( + "health_check_failed", + "Engine health check failed.", + "Engine health check failed after {attempts} attempts: {reason}" + )] + HealthCheckFailed { attempts: u32, reason: String }, + + #[error( + "port_occupied", + "Engine port is occupied by a different runtime.", + "Cannot start engine: endpoint '{endpoint}' is already serving runtime '{runtime}'. Stop that process and retry." + )] + PortOccupied { endpoint: String, runtime: String }, +} diff --git a/rivetkit-rust/packages/engine-process/src/lib.rs b/rivetkit-rust/packages/engine-process/src/lib.rs new file mode 100644 index 0000000000..a9bc2b0c28 --- /dev/null +++ b/rivetkit-rust/packages/engine-process/src/lib.rs @@ -0,0 +1,996 @@ +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use reqwest::{Client, Url}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use tokio::process::{Child, Command}; +use tokio::task::JoinHandle; + +mod error; + +pub use error::EngineProcessError; + +const ENGINE_RUNTIME: &str = "engine"; +const RIVETKIT_RUNTIME: &str = "rivetkit"; +const ENGINE_VERSION_ENV: &str = "RIVETKIT_ENGINE_VERSION"; +const RELEASES_ENDPOINT_ENV: &str = "RIVETKIT_ENGINE_RELEASES_ENDPOINT"; +const RELEASES_ENDPOINT: &str = "https://releases.rivet.dev"; +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60); + +#[derive(Debug, Deserialize)] +struct EngineHealthResponse { + status: Option, + runtime: Option, + version: Option, +} + +#[derive(Clone, Debug)] +pub struct EngineResolverConfig { + pub endpoint: String, + pub explicit_binary_path: Option, + pub bind_host: Option, + pub bind_port: Option, + pub public_url: Option, + pub auto_download: bool, + pub version: String, + pub releases_endpoint: String, +} + +impl EngineResolverConfig { + pub fn from_parts( + endpoint: &str, + explicit_binary_path: Option, + bind_host: Option, + bind_port: Option, + auto_download: bool, + ) -> Self { + Self { + endpoint: endpoint.to_owned(), + explicit_binary_path, + bind_host, + bind_port, + public_url: None, + auto_download, + version: std::env::var(ENGINE_VERSION_ENV) + .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_owned()), + releases_endpoint: std::env::var(RELEASES_ENDPOINT_ENV) + .unwrap_or_else(|_| RELEASES_ENDPOINT.to_owned()), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ResolvedEngine { + Existing, + Binary(PathBuf), +} + +/// Manages the rivet-engine subprocess. +/// +/// The engine is intentionally orphaned: dropping the manager (or having the +/// host process exit) must NOT terminate the engine. This lets a dev-server +/// restart of the rivetkit host reattach to the same long-lived engine and +/// keep all in-flight actor state. To honor that contract: +/// +/// - `Command::kill_on_drop` is left at its default (false) so the tokio +/// `Child` does not send SIGKILL on drop. +/// - Stdout and stderr are routed to log files at spawn time so the engine's +/// write fds remain valid after the host's pipes close. +/// - On startup we probe the configured endpoint and reuse a healthy engine +/// instead of spawning a duplicate. +/// +/// When we spawn the engine, `watcher` holds a tokio task that owns the +/// `Child` and awaits `child.wait()` so we get a log line if the engine dies +/// while rivetkit is still running. On Drop we abort the watcher; aborting +/// drops the `Child` without killing it (kill_on_drop=false), so the engine +/// stays running and gets reparented to init when rivetkit exits. +/// +/// `watcher` is `None` when we attached to an already-running engine. +#[derive(Debug)] +pub struct EngineProcessManager { + watcher: Option>, +} + +impl EngineProcessManager { + pub async fn start_or_reuse(config: EngineResolverConfig) -> Result { + let resolved = resolve_engine_binary(&config).await?; + Self::start_resolved(resolved, &config).await + } + + async fn start_resolved( + resolved: ResolvedEngine, + config: &EngineResolverConfig, + ) -> Result { + let endpoint = &config.endpoint; + if matches!(resolved, ResolvedEngine::Existing) { + tracing::info!( + endpoint = %endpoint, + "reusing already-running engine process" + ); + return Ok(Self { watcher: None }); + } + + let ResolvedEngine::Binary(binary_path) = resolved else { + unreachable!("existing engine handled above"); + }; + if let Some(health) = probe_existing_engine(endpoint).await? { + tracing::info!( + endpoint = %endpoint, + status = ?health.status, + runtime = ?health.runtime, + version = ?health.version, + "reusing already-running engine process" + ); + return Ok(Self { watcher: None }); + } + + if !binary_path.exists() { + return Err(EngineProcessError::BinaryNotFound { + path: binary_path.display().to_string(), + } + .build()); + } + + let env = engine_env(config)?; + let config_path = write_engine_config(config)?; + let db_path = engine_db_path()?; + let logs_dir = storage_root()? + .join("var") + .join("logs") + .join("rivet-engine"); + ensure_dir(&db_path).context("create engine db directory")?; + ensure_dir(&logs_dir).context("create engine logs directory")?; + + let timestamp = log_timestamp(); + let stdout_log_path = logs_dir.join(format!("engine-{timestamp}-stdout.log")); + let stderr_log_path = logs_dir.join(format!("engine-{timestamp}-stderr.log")); + let stdout_file = open_log_file(&stdout_log_path) + .with_context(|| format!("open engine stdout log `{}`", stdout_log_path.display()))?; + let stderr_file = open_log_file(&stderr_log_path) + .with_context(|| format!("open engine stderr log `{}`", stderr_log_path.display()))?; + + let mut command = Command::new(&binary_path); + command.arg("start"); + if let Some(config_path) = &config_path { + command.arg("--config").arg(config_path); + } + for (key, value) in &env { + command.env(key, value); + } + command + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout_file)) + .stderr(Stdio::from(stderr_file)); + + // Put the engine in its own process group so terminal signals + // (Ctrl+C, Ctrl+Z, SIGHUP on terminal close) targeting our foreground + // process group do not reach the engine. Combined with no-kill-on-drop + // and file-fd stdio, this gives the engine a real "intentional orphan" + // lifetime that survives the host being killed for any reason. + #[cfg(unix)] + command.process_group(0); + + let mut child = command + .spawn() + .with_context(|| format!("spawn engine binary `{}`", binary_path.display()))?; + let pid = child + .id() + .ok_or_else(|| EngineProcessError::MissingPid.build())?; + + tracing::info!( + pid, + path = %binary_path.display(), + endpoint = %endpoint, + db_path = %db_path.display(), + "spawned engine process (intentionally orphaned, will outlive this process)" + ); + tracing::info!( + stdout_log = %stdout_log_path.display(), + stderr_log = %stderr_log_path.display(), + "engine stdout/stderr piped to log files" + ); + + let health_url = engine_health_url(endpoint); + let health = match wait_for_engine_health(&health_url).await { + Ok(health) => health, + Err(error) => { + let error = match child.try_wait() { + Ok(Some(status)) => error.context(format!( + "engine process exited before becoming healthy with status {status}" + )), + Ok(None) => error, + Err(wait_error) => error.context(format!( + "failed to inspect engine process status: {wait_error:#}" + )), + }; + if let Err(cleanup_error) = terminate_failed_spawn(&mut child).await { + tracing::warn!( + ?cleanup_error, + "failed to terminate engine process that never became healthy" + ); + } + return Err(error); + } + }; + + tracing::info!( + pid, + status = ?health.status, + runtime = ?health.runtime, + version = ?health.version, + "engine process is healthy" + ); + + Ok(Self { + watcher: Some(spawn_engine_watcher(child, pid)), + }) + } +} + +/// Path to the rivet-engine database directory under the shared storage root. +pub fn engine_db_path() -> Result { + Ok(storage_root()?.join("var").join("engine").join("db")) +} + +/// Computes the environment variables that configure a rivet-engine process +/// for the given endpoint. +/// +/// Shared by the spawn path and by callers that exec the engine binary +/// directly (for example the CLI `engine` proxy) so both operate on the same +/// database, guard, api-peer, and metrics ports. +pub fn engine_env(config: &EngineResolverConfig) -> Result> { + let endpoint = &config.endpoint; + let endpoint_url = + Url::parse(endpoint).with_context(|| format!("parse engine endpoint `{endpoint}`"))?; + let guard_host = endpoint_url + .host_str() + .ok_or_else(|| invalid_endpoint(endpoint, "missing host"))? + .to_owned(); + let guard_host = config.bind_host.clone().unwrap_or(guard_host); + let guard_port = endpoint_url + .port_or_known_default() + .ok_or_else(|| invalid_endpoint(endpoint, "missing port"))?; + let guard_port = config.bind_port.unwrap_or(guard_port); + let api_peer_port = guard_port + .checked_add(1) + .ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?; + let metrics_port = guard_port + .checked_add(10) + .ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?; + + let db_path = engine_db_path()?; + + Ok(vec![ + ("RIVET__GUARD__HOST".to_owned(), guard_host.clone()), + ("RIVET__GUARD__PORT".to_owned(), guard_port.to_string()), + ("RIVET__API_PEER__HOST".to_owned(), guard_host.clone()), + ( + "RIVET__API_PEER__PORT".to_owned(), + api_peer_port.to_string(), + ), + ("RIVET__METRICS__HOST".to_owned(), guard_host), + ("RIVET__METRICS__PORT".to_owned(), metrics_port.to_string()), + ( + "RIVET__FILE_SYSTEM__PATH".to_owned(), + db_path.to_string_lossy().into_owned(), + ), + ]) +} + +fn write_engine_config(config: &EngineResolverConfig) -> Result> { + let Some(public_url) = &config.public_url else { + return Ok(None); + }; + + let public_url = Url::parse(public_url) + .with_context(|| format!("parse engine public URL `{public_url}`"))?; + let peer_url = peer_url_for_public_url(&public_url)?; + let dir = storage_root()?.join("var").join("engine"); + ensure_dir(&dir)?; + let path = dir.join("config.json"); + let config = serde_json::json!({ + "topology": { + "datacenter_label": 1, + "datacenters": { + "default": { + "datacenter_label": 1, + "is_leader": true, + "public_url": public_url.as_str(), + "peer_url": peer_url, + } + } + } + }); + std::fs::write(&path, serde_json::to_string_pretty(&config)?) + .with_context(|| format!("write engine config `{}`", path.display()))?; + Ok(Some(path)) +} + +fn peer_url_for_public_url(public_url: &Url) -> Result { + let mut peer_url = public_url.clone(); + let port = public_url + .port_or_known_default() + .ok_or_else(|| invalid_endpoint(public_url.as_str(), "missing port"))? + .checked_add(1) + .ok_or_else(|| invalid_endpoint(public_url.as_str(), "port is too large"))?; + if peer_url.set_port(Some(port)).is_err() { + return Err(invalid_endpoint( + public_url.as_str(), + "could not derive peer URL port", + )); + } + peer_url.set_path(""); + peer_url.set_query(None); + peer_url.set_fragment(None); + Ok(peer_url.to_string().trim_end_matches('/').to_string()) +} + +pub async fn resolve_engine_binary(config: &EngineResolverConfig) -> Result { + if let Some(path) = config.explicit_binary_path.as_ref() { + return verify_binary_path(path); + } + + if let Some(path) = std::env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from) { + return verify_binary_path(&path); + } + + if probe_existing_engine(&config.endpoint).await?.is_some() { + return Ok(ResolvedEngine::Existing); + } + + let local_roots = local_engine_search_roots(); + let cached = cached_engine_path(&config.version)?; + resolve_engine_binary_after_probe(config, false, &local_roots, cached).await +} + +/// Resolves the engine binary path without probing for an already-running +/// engine. +/// +/// Used by callers that exec the binary directly (for example the CLI `engine` +/// proxy), where a running engine on the endpoint should not short-circuit +/// resolution to `Existing` and leave the caller without a path to run. +pub async fn resolve_engine_binary_path(config: &EngineResolverConfig) -> Result { + if let Some(path) = config.explicit_binary_path.as_ref() { + verify_binary_path(path)?; + return Ok(path.clone()); + } + + if let Some(path) = std::env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from) { + verify_binary_path(&path)?; + return Ok(path); + } + + let local_roots = local_engine_search_roots(); + let cached = cached_engine_path(&config.version)?; + match resolve_engine_binary_after_probe(config, false, &local_roots, cached).await? { + ResolvedEngine::Binary(path) => Ok(path), + ResolvedEngine::Existing => { + unreachable!("no-probe resolution never returns Existing") + } + } +} + +async fn resolve_engine_binary_after_probe( + config: &EngineResolverConfig, + existing_engine: bool, + local_roots: &[PathBuf], + cached: PathBuf, +) -> Result { + if existing_engine { + return Ok(ResolvedEngine::Existing); + } + + if let Some(path) = find_local_engine_binary_in_roots(local_roots) { + return Ok(ResolvedEngine::Binary(path)); + } + + if cached.exists() { + return Ok(ResolvedEngine::Binary(cached)); + } + + if !config.auto_download { + return Err(EngineProcessError::BinaryUnavailable { + version: config.version.clone(), + } + .build()); + } + + download_engine_binary(config, &cached).await?; + Ok(ResolvedEngine::Binary(cached)) +} + +fn verify_binary_path(path: &Path) -> Result { + if !path.exists() { + return Err(EngineProcessError::BinaryNotFound { + path: path.display().to_string(), + } + .build()); + } + Ok(ResolvedEngine::Binary(path.to_path_buf())) +} + +fn local_engine_search_roots() -> Vec { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .map(Path::to_path_buf) + .collect() +} + +fn find_local_engine_binary_in_roots(roots: &[PathBuf]) -> Option { + for root in roots { + for profile in ["debug", "release"] { + let candidate = root + .join("target") + .join(profile) + .join(exe_name("rivet-engine")); + if candidate.exists() { + return Some(candidate); + } + } + } + None +} + +fn cached_engine_path(version: &str) -> Result { + Ok(storage_root()? + .join("engine") + .join(version) + .join(engine_artifact_name())) +} + +async fn download_engine_binary(config: &EngineResolverConfig, destination: &Path) -> Result<()> { + let artifact = engine_artifact_name(); + let base = config.releases_endpoint.trim_end_matches('/'); + let artifact_url = format!("{base}/rivet/{}/engine/{artifact}", config.version); + let manifest_url = format!("{base}/rivet/{}/engine/SHA256SUMS", config.version); + let client = Client::builder() + .timeout(DOWNLOAD_TIMEOUT) + .build() + .context("build reqwest client for engine download")?; + + let manifest = fetch_text(&client, &manifest_url).await?; + let expected = checksum_for_artifact(&manifest, &artifact).ok_or_else(|| { + EngineProcessError::DownloadFailed { + url: manifest_url.clone(), + reason: format!("manifest does not contain `{artifact}`"), + } + .build() + })?; + + let bytes = fetch_bytes(&client, &artifact_url).await?; + let received = sha256_hex(&bytes); + if !received.eq_ignore_ascii_case(&expected) { + return Err(EngineProcessError::ChecksumMismatch { + artifact, + expected, + received, + } + .build()); + } + + let parent = destination + .parent() + .context("engine cache destination has no parent")?; + ensure_dir(parent)?; + std::fs::write(destination, bytes) + .with_context(|| format!("write engine binary `{}`", destination.display()))?; + make_executable(destination)?; + Ok(()) +} + +async fn fetch_text(client: &Client, url: &str) -> Result { + let response = client.get(url).send().await.map_err(|error| { + EngineProcessError::DownloadFailed { + url: url.to_owned(), + reason: error.to_string(), + } + .build() + })?; + if !response.status().is_success() { + let status = response.status(); + return Err(EngineProcessError::DownloadFailed { + url: url.to_owned(), + reason: format!("unexpected status {status}"), + } + .build()); + } + response.text().await.map_err(|error| { + EngineProcessError::DownloadFailed { + url: url.to_owned(), + reason: error.to_string(), + } + .build() + }) +} + +async fn fetch_bytes(client: &Client, url: &str) -> Result> { + let response = client.get(url).send().await.map_err(|error| { + EngineProcessError::DownloadFailed { + url: url.to_owned(), + reason: error.to_string(), + } + .build() + })?; + if !response.status().is_success() { + let status = response.status(); + return Err(EngineProcessError::DownloadFailed { + url: url.to_owned(), + reason: format!("unexpected status {status}"), + } + .build()); + } + response + .bytes() + .await + .map(|bytes| bytes.to_vec()) + .map_err(|error| { + EngineProcessError::DownloadFailed { + url: url.to_owned(), + reason: error.to_string(), + } + .build() + }) +} + +fn checksum_for_artifact(manifest: &str, artifact: &str) -> Option { + manifest.lines().find_map(|line| { + let mut parts = line.split_whitespace(); + let checksum = parts.next()?; + let name = parts.next()?.trim_start_matches('*'); + (checksum.len() == 64 && name == artifact).then(|| checksum.to_owned()) + }) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut out = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write; + let _ = write!(&mut out, "{byte:02x}"); + } + out +} + +fn engine_artifact_name() -> String { + let arch = match std::env::consts::ARCH { + "x86_64" => "x86_64", + "aarch64" => "aarch64", + other => other, + }; + let target = match std::env::consts::OS { + "linux" => format!("{arch}-unknown-linux-musl"), + "macos" => format!("{arch}-apple-darwin"), + "windows" => format!("{arch}-pc-windows-gnu.exe"), + other => format!("{arch}-{other}"), + }; + format!("rivet-engine-{target}") +} + +fn exe_name(base: &str) -> String { + if cfg!(windows) { + format!("{base}.exe") + } else { + base.to_owned() + } +} + +fn make_executable(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(path) + .with_context(|| format!("read metadata for `{}`", path.display()))? + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions) + .with_context(|| format!("mark `{}` executable", path.display()))?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} + +impl Drop for EngineProcessManager { + fn drop(&mut self) { + if let Some(handle) = self.watcher.take() { + // Aborting drops the `Child` owned by the task. With + // `kill_on_drop=false`, dropping the `Child` does NOT signal the + // engine, so the engine survives and gets reparented to init. + // We give up our crash-detection log line here, but if we are + // being dropped the rivetkit host is shutting down anyway. + handle.abort(); + tracing::debug!( + "aborted engine watcher; engine continues running (intentional orphan)" + ); + } + } +} + +/// Spawns a background task that owns the `Child` and awaits `wait()` so we +/// log a clear message if the engine dies while rivetkit is still up. Taking +/// the `Child` into the task also reaps it via `waitpid` on exit, so a +/// crashed engine never lingers as a zombie in our process table. +fn spawn_engine_watcher(mut child: Child, pid: u32) -> JoinHandle<()> { + tokio::spawn(async move { + match child.wait().await { + Ok(status) if status.success() => { + tracing::warn!( + pid, + ?status, + "engine process exited cleanly while rivetkit was still running; \ + rivetkit expected the engine to outlive it" + ); + } + Ok(status) => { + tracing::error!( + pid, + ?status, + "engine process crashed while rivetkit was still running" + ); + } + Err(error) => { + tracing::error!( + pid, + ?error, + "failed to wait on engine process; cannot detect crashes" + ); + } + } + }) +} + +/// Probes the configured endpoint for an already-running, healthy engine. +/// +/// Returns `Ok(Some(health))` if the endpoint is serving a `runtime: "engine"` +/// health response that we can reattach to. Returns `Ok(None)` if the port is +/// free. Returns `Err(...)` if the port is occupied by a non-engine process +/// (for example a stale rivetkit) which would conflict with a fresh spawn. +async fn probe_existing_engine(endpoint: &str) -> Result> { + let health_url = engine_health_url(endpoint); + let client = Client::builder() + .build() + .context("build reqwest client for engine probe")?; + + let response = match client + .get(&health_url) + .timeout(Duration::from_secs(1)) + .send() + .await + { + Ok(response) => response, + Err(_) => return Ok(None), + }; + + if !response.status().is_success() { + return Ok(None); + } + + let health = response + .json::() + .await + .context("decode existing engine health response")?; + + match health.runtime.as_deref() { + Some(ENGINE_RUNTIME) => Ok(Some(health)), + Some(RIVETKIT_RUNTIME) => Err(EngineProcessError::PortOccupied { + endpoint: endpoint.to_owned(), + runtime: RIVETKIT_RUNTIME.to_owned(), + } + .build()), + Some(other) => Err(EngineProcessError::PortOccupied { + endpoint: endpoint.to_owned(), + runtime: other.to_owned(), + } + .build()), + None => Err(EngineProcessError::PortOccupied { + endpoint: endpoint.to_owned(), + runtime: "unknown".to_owned(), + } + .build()), + } +} + +fn engine_health_url(endpoint: &str) -> String { + format!("{}/health", endpoint.trim_end_matches('/')) +} + +fn storage_root() -> Result { + if let Ok(path) = std::env::var("RIVETKIT_STORAGE_PATH") { + return Ok(PathBuf::from(path).join(".rivetkit")); + } + let home = std::env::var("HOME") + .map(PathBuf::from) + .or_else(|_| std::env::current_dir()) + .context("locate home directory for engine storage path")?; + Ok(home.join(".rivetkit")) +} + +fn ensure_dir(path: &Path) -> Result<()> { + std::fs::create_dir_all(path).with_context(|| format!("create directory `{}`", path.display())) +} + +fn open_log_file(path: &Path) -> Result { + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .with_context(|| format!("open log file `{}`", path.display())) +} + +fn log_timestamp() -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + format!("{}", now.as_secs()) +} + +async fn wait_for_engine_health(health_url: &str) -> Result { + const HEALTH_MAX_WAIT: Duration = Duration::from_secs(10); + const HEALTH_REQUEST_TIMEOUT: Duration = Duration::from_secs(1); + const HEALTH_INITIAL_BACKOFF: Duration = Duration::from_millis(100); + const HEALTH_MAX_BACKOFF: Duration = Duration::from_secs(1); + + let client = Client::builder() + .build() + .context("build reqwest client for engine health check")?; + let deadline = Instant::now() + HEALTH_MAX_WAIT; + let mut attempt = 0u32; + let mut backoff = HEALTH_INITIAL_BACKOFF; + + loop { + attempt += 1; + + let last_error = match client + .get(health_url) + .timeout(HEALTH_REQUEST_TIMEOUT) + .send() + .await + { + Ok(response) if response.status().is_success() => { + let health = response + .json::() + .await + .context("decode engine health response")?; + return Ok(health); + } + Ok(response) => format!("unexpected status {}", response.status()), + Err(error) => error.to_string(), + }; + + if Instant::now() >= deadline { + return Err(EngineProcessError::HealthCheckFailed { + attempts: attempt, + reason: last_error, + } + .build()); + } + + tokio::time::sleep(backoff).await; + backoff = std::cmp::min(backoff * 2, HEALTH_MAX_BACKOFF); + } +} + +/// Cleanup path for a spawn that never reached `healthy`. We *do* kill here +/// because the half-started engine has no useful state to preserve and +/// leaving it running would conflict with a retry. This is the only place +/// allowed to terminate the engine. +async fn terminate_failed_spawn(child: &mut Child) -> Result<()> { + const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); + + if child + .try_wait() + .context("check engine process status")? + .is_some() + { + return Ok(()); + } + + child + .start_kill() + .context("kill half-started engine process")?; + match tokio::time::timeout(SHUTDOWN_TIMEOUT, child.wait()).await { + Ok(result) => { + let status = result.context("wait for half-started engine to exit")?; + tracing::info!(?status, "half-started engine process exited"); + Ok(()) + } + Err(_) => { + tracing::warn!("half-started engine process did not exit within timeout"); + Ok(()) + } + } +} + +fn invalid_endpoint(endpoint: &str, reason: &str) -> anyhow::Error { + EngineProcessError::InvalidEndpoint { + endpoint: endpoint.to_owned(), + reason: reason.to_owned(), + } + .build() +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + use super::*; + + fn test_config(releases_endpoint: String, auto_download: bool) -> EngineResolverConfig { + EngineResolverConfig { + endpoint: "http://127.0.0.1:1".to_owned(), + explicit_binary_path: None, + bind_host: None, + bind_port: None, + public_url: None, + auto_download, + version: "test-version".to_owned(), + releases_endpoint, + } + } + + #[tokio::test] + async fn resolver_prefers_existing_engine_before_filesystem_paths() { + let temp = tempfile::tempdir().expect("create temp dir"); + let local = temp + .path() + .join("target") + .join("debug") + .join(exe_name("rivet-engine")); + std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir"); + std::fs::write(&local, b"local").expect("write local binary"); + let cached = temp.path().join("cache").join(exe_name("rivet-engine")); + + let resolved = resolve_engine_binary_after_probe( + &test_config("http://127.0.0.1:1".to_owned(), false), + true, + &[temp.path().to_path_buf()], + cached, + ) + .await + .expect("resolve engine"); + + assert_eq!(resolved, ResolvedEngine::Existing); + } + + #[tokio::test] + async fn resolver_prefers_local_binary_before_cached_binary() { + let temp = tempfile::tempdir().expect("create temp dir"); + let local = temp + .path() + .join("target") + .join("debug") + .join(exe_name("rivet-engine")); + std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir"); + std::fs::write(&local, b"local").expect("write local binary"); + let cached = temp.path().join("cache").join(exe_name("rivet-engine")); + std::fs::create_dir_all(cached.parent().expect("cached parent")).expect("create cache dir"); + std::fs::write(&cached, b"cached").expect("write cached binary"); + + let resolved = resolve_engine_binary_after_probe( + &test_config("http://127.0.0.1:1".to_owned(), false), + false, + &[temp.path().to_path_buf()], + cached, + ) + .await + .expect("resolve engine"); + + assert_eq!(resolved, ResolvedEngine::Binary(local)); + } + + #[tokio::test] + async fn resolver_reuses_cached_binary_without_download() { + let temp = tempfile::tempdir().expect("create temp dir"); + let cached = temp.path().join("cache").join(exe_name("rivet-engine")); + std::fs::create_dir_all(cached.parent().expect("cached parent")).expect("create cache dir"); + std::fs::write(&cached, b"cached").expect("write cached binary"); + + let resolved = resolve_engine_binary_after_probe( + &test_config("http://127.0.0.1:1".to_owned(), false), + false, + &[], + cached.clone(), + ) + .await + .expect("resolve engine"); + + assert_eq!(resolved, ResolvedEngine::Binary(cached)); + } + + #[tokio::test] + async fn resolver_reports_actionable_error_without_binary_or_download() { + let temp = tempfile::tempdir().expect("create temp dir"); + let cached = temp.path().join("cache").join(exe_name("rivet-engine")); + + let error = resolve_engine_binary_after_probe( + &test_config("http://127.0.0.1:1".to_owned(), false), + false, + &[], + cached, + ) + .await + .expect_err("missing binary should fail"); + let message = error.to_string(); + + assert!(message.contains("No usable engine binary was found")); + assert!(message.contains("Build `rivet-engine`")); + assert!(message.contains("RIVET_ENGINE_BINARY_PATH")); + } + + #[tokio::test] + async fn resolver_download_checks_manifest_checksum() { + let temp = tempfile::tempdir().expect("create temp dir"); + let cached = temp.path().join("cache").join(exe_name("rivet-engine")); + let artifact = engine_artifact_name(); + let expected = sha256_hex(b"different bytes"); + let manifest = format!("{expected} {artifact}\n"); + let releases_endpoint = spawn_download_server(HashMap::from([ + ( + format!("/rivet/test-version/engine/SHA256SUMS"), + manifest.into_bytes(), + ), + ( + format!("/rivet/test-version/engine/{artifact}"), + b"actual bytes".to_vec(), + ), + ])) + .await; + + let error = resolve_engine_binary_after_probe( + &test_config(releases_endpoint, true), + false, + &[], + cached, + ) + .await + .expect_err("checksum mismatch should fail"); + + assert!( + error + .to_string() + .contains("Engine binary checksum mismatch") + ); + } + + async fn spawn_download_server(routes: HashMap>) -> String { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind download server"); + let addr = listener.local_addr().expect("download server address"); + tokio::spawn(async move { + for _ in 0..routes.len() { + let (mut socket, _) = listener.accept().await.expect("accept download request"); + let mut buffer = [0_u8; 2048]; + let n = socket + .read(&mut buffer) + .await + .expect("read download request"); + let request = String::from_utf8_lossy(&buffer[..n]); + let path = request + .split_whitespace() + .nth(1) + .expect("request path") + .to_owned(); + let body = routes.get(&path).expect("route body"); + let header = format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + body.len() + ); + socket + .write_all(header.as_bytes()) + .await + .expect("write response header"); + socket.write_all(body).await.expect("write response body"); + } + }); + format!("http://{addr}") + } +} diff --git a/rivetkit-rust/packages/rivetkit-core/Cargo.toml b/rivetkit-rust/packages/rivetkit-core/Cargo.toml index b11d713ea3..53e3e5a52c 100644 --- a/rivetkit-rust/packages/rivetkit-core/Cargo.toml +++ b/rivetkit-rust/packages/rivetkit-core/Cargo.toml @@ -15,6 +15,7 @@ default = ["native-runtime"] native-runtime = [ "dep:nix", "dep:reqwest", + "dep:rivetkit-engine-process", "rivet-envoy-client/native-transport", ] wasm-runtime = ["rivet-envoy-client/wasm-transport"] @@ -44,6 +45,7 @@ rivet-metrics.workspace = true rivetkit-shared-types.workspace = true rivetkit-actor-persist.workspace = true rivetkit-client-protocol.workspace = true +rivetkit-engine-process = { workspace = true, optional = true } rivetkit-inspector-protocol.workspace = true depot-client-types.workspace = true depot-client = { workspace = true, optional = true } diff --git a/rivetkit-rust/packages/rivetkit-core/src/engine_process.rs b/rivetkit-rust/packages/rivetkit-core/src/engine_process.rs index 305f43d6e0..345b8e04dc 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/engine_process.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/engine_process.rs @@ -1,884 +1,9 @@ -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::time::{Duration, Instant}; - -use anyhow::{Context, Result}; -use reqwest::{Client, Url}; -use serde::Deserialize; -use sha2::{Digest, Sha256}; -use tokio::process::{Child, Command}; -use tokio::task::JoinHandle; - -use crate::error::EngineProcessError; - -const ENGINE_RUNTIME: &str = "engine"; -const RIVETKIT_RUNTIME: &str = "rivetkit"; -const ENGINE_VERSION_ENV: &str = "RIVETKIT_ENGINE_VERSION"; -const RELEASES_ENDPOINT_ENV: &str = "RIVETKIT_ENGINE_RELEASES_ENDPOINT"; -const RELEASES_ENDPOINT: &str = "https://releases.rivet.dev"; -const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60); - -#[derive(Debug, Deserialize)] -struct EngineHealthResponse { - status: Option, - runtime: Option, - version: Option, -} - -#[derive(Clone, Debug)] -pub(crate) struct EngineResolverConfig { - pub endpoint: String, - pub explicit_binary_path: Option, - pub bind_host: Option, - pub bind_port: Option, - pub auto_download: bool, - pub version: String, - pub releases_endpoint: String, -} - -impl EngineResolverConfig { - pub(crate) fn from_parts( - endpoint: &str, - explicit_binary_path: Option, - bind_host: Option, - bind_port: Option, - auto_download: bool, - ) -> Self { - Self { - endpoint: endpoint.to_owned(), - explicit_binary_path, - bind_host, - bind_port, - auto_download, - version: std::env::var(ENGINE_VERSION_ENV) - .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_owned()), - releases_endpoint: std::env::var(RELEASES_ENDPOINT_ENV) - .unwrap_or_else(|_| RELEASES_ENDPOINT.to_owned()), - } - } -} - -#[derive(Debug, PartialEq, Eq)] -enum ResolvedEngine { - Existing, - Binary(PathBuf), -} - -/// Manages the rivet-engine subprocess. -/// -/// The engine is intentionally orphaned: dropping the manager (or having the -/// host process exit) must NOT terminate the engine. This lets a dev-server -/// restart of the rivetkit host reattach to the same long-lived engine and -/// keep all in-flight actor state. To honor that contract: -/// -/// - `Command::kill_on_drop` is left at its default (false) so the tokio -/// `Child` does not send SIGKILL on drop. -/// - Stdout and stderr are routed to log files at spawn time so the engine's -/// write fds remain valid after the host's pipes close. -/// - On startup we probe the configured endpoint and reuse a healthy engine -/// instead of spawning a duplicate. -/// -/// When we spawn the engine, `watcher` holds a tokio task that owns the -/// `Child` and awaits `child.wait()` so we get a log line if the engine dies -/// while rivetkit is still running. On Drop we abort the watcher; aborting -/// drops the `Child` without killing it (kill_on_drop=false), so the engine -/// stays running and gets reparented to init when rivetkit exits. -/// -/// `watcher` is `None` when we attached to an already-running engine. -#[derive(Debug)] -pub(crate) struct EngineProcessManager { - watcher: Option>, -} - -impl EngineProcessManager { - pub(crate) async fn start_or_reuse(config: EngineResolverConfig) -> Result { - let resolved = resolve_engine_binary(&config).await?; - Self::start_resolved(resolved, &config).await - } - - async fn start_resolved( - resolved: ResolvedEngine, - config: &EngineResolverConfig, - ) -> Result { - let endpoint = &config.endpoint; - if matches!(resolved, ResolvedEngine::Existing) { - tracing::info!( - endpoint = %endpoint, - "reusing already-running engine process" - ); - return Ok(Self { watcher: None }); - } - - let ResolvedEngine::Binary(binary_path) = resolved else { - unreachable!("existing engine handled above"); - }; - if let Some(health) = probe_existing_engine(endpoint).await? { - tracing::info!( - endpoint = %endpoint, - status = ?health.status, - runtime = ?health.runtime, - version = ?health.version, - "reusing already-running engine process" - ); - return Ok(Self { watcher: None }); - } - - if !binary_path.exists() { - return Err(EngineProcessError::BinaryNotFound { - path: binary_path.display().to_string(), - } - .build()); - } - - let endpoint_url = - Url::parse(endpoint).with_context(|| format!("parse engine endpoint `{endpoint}`"))?; - let guard_host = endpoint_url - .host_str() - .ok_or_else(|| invalid_endpoint(endpoint, "missing host"))? - .to_owned(); - let guard_host = config.bind_host.clone().unwrap_or(guard_host); - let guard_port = endpoint_url - .port_or_known_default() - .ok_or_else(|| invalid_endpoint(endpoint, "missing port"))?; - let guard_port = config.bind_port.unwrap_or(guard_port); - let api_peer_port = guard_port - .checked_add(1) - .ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?; - let metrics_port = guard_port - .checked_add(10) - .ok_or_else(|| invalid_endpoint(endpoint, "port is too large"))?; - - let storage_root = storage_root()?; - let var_dir = storage_root.join("var"); - let db_path = var_dir.join("engine").join("db"); - let logs_dir = var_dir.join("logs").join("rivet-engine"); - ensure_dir(&db_path).context("create engine db directory")?; - ensure_dir(&logs_dir).context("create engine logs directory")?; - - let timestamp = log_timestamp(); - let stdout_log_path = logs_dir.join(format!("engine-{timestamp}-stdout.log")); - let stderr_log_path = logs_dir.join(format!("engine-{timestamp}-stderr.log")); - let stdout_file = open_log_file(&stdout_log_path) - .with_context(|| format!("open engine stdout log `{}`", stdout_log_path.display()))?; - let stderr_file = open_log_file(&stderr_log_path) - .with_context(|| format!("open engine stderr log `{}`", stderr_log_path.display()))?; - - let mut command = Command::new(&binary_path); - command - .arg("start") - .env("RIVET__GUARD__HOST", &guard_host) - .env("RIVET__GUARD__PORT", guard_port.to_string()) - .env("RIVET__API_PEER__HOST", &guard_host) - .env("RIVET__API_PEER__PORT", api_peer_port.to_string()) - .env("RIVET__METRICS__HOST", &guard_host) - .env("RIVET__METRICS__PORT", metrics_port.to_string()) - .env("RIVET__FILE_SYSTEM__PATH", &db_path) - .stdin(Stdio::null()) - .stdout(Stdio::from(stdout_file)) - .stderr(Stdio::from(stderr_file)); - - // Put the engine in its own process group so terminal signals - // (Ctrl+C, Ctrl+Z, SIGHUP on terminal close) targeting our foreground - // process group do not reach the engine. Combined with no-kill-on-drop - // and file-fd stdio, this gives the engine a real "intentional orphan" - // lifetime that survives the host being killed for any reason. - #[cfg(unix)] - command.process_group(0); - - let mut child = command - .spawn() - .with_context(|| format!("spawn engine binary `{}`", binary_path.display()))?; - let pid = child - .id() - .ok_or_else(|| EngineProcessError::MissingPid.build())?; - - tracing::info!( - pid, - path = %binary_path.display(), - endpoint = %endpoint, - db_path = %db_path.display(), - "spawned engine process (intentionally orphaned, will outlive this process)" - ); - tracing::info!( - stdout_log = %stdout_log_path.display(), - stderr_log = %stderr_log_path.display(), - "engine stdout/stderr piped to log files" - ); - - let health_url = engine_health_url(endpoint); - let health = match wait_for_engine_health(&health_url).await { - Ok(health) => health, - Err(error) => { - let error = match child.try_wait() { - Ok(Some(status)) => error.context(format!( - "engine process exited before becoming healthy with status {status}" - )), - Ok(None) => error, - Err(wait_error) => error.context(format!( - "failed to inspect engine process status: {wait_error:#}" - )), - }; - if let Err(cleanup_error) = terminate_failed_spawn(&mut child).await { - tracing::warn!( - ?cleanup_error, - "failed to terminate engine process that never became healthy" - ); - } - return Err(error); - } - }; - - tracing::info!( - pid, - status = ?health.status, - runtime = ?health.runtime, - version = ?health.version, - "engine process is healthy" - ); - - Ok(Self { - watcher: Some(spawn_engine_watcher(child, pid)), - }) - } -} - -async fn resolve_engine_binary(config: &EngineResolverConfig) -> Result { - if let Some(path) = config.explicit_binary_path.as_ref() { - return verify_binary_path(path); - } - - if let Some(path) = std::env::var_os("RIVET_ENGINE_BINARY_PATH").map(PathBuf::from) { - return verify_binary_path(&path); - } - - if probe_existing_engine(&config.endpoint).await?.is_some() { - return Ok(ResolvedEngine::Existing); - } - - let local_roots = local_engine_search_roots(); - let cached = cached_engine_path(&config.version)?; - resolve_engine_binary_after_probe(config, false, &local_roots, cached).await -} - -async fn resolve_engine_binary_after_probe( - config: &EngineResolverConfig, - existing_engine: bool, - local_roots: &[PathBuf], - cached: PathBuf, -) -> Result { - if existing_engine { - return Ok(ResolvedEngine::Existing); - } - - if let Some(path) = find_local_engine_binary_in_roots(local_roots) { - return Ok(ResolvedEngine::Binary(path)); - } - - if cached.exists() { - return Ok(ResolvedEngine::Binary(cached)); - } - - if !config.auto_download { - return Err(EngineProcessError::BinaryUnavailable { - version: config.version.clone(), - } - .build()); - } - - download_engine_binary(config, &cached).await?; - Ok(ResolvedEngine::Binary(cached)) -} - -fn verify_binary_path(path: &Path) -> Result { - if !path.exists() { - return Err(EngineProcessError::BinaryNotFound { - path: path.display().to_string(), - } - .build()); - } - Ok(ResolvedEngine::Binary(path.to_path_buf())) -} - -fn local_engine_search_roots() -> Vec { - Path::new(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .map(Path::to_path_buf) - .collect() -} - -fn find_local_engine_binary_in_roots(roots: &[PathBuf]) -> Option { - for root in roots { - for profile in ["debug", "release"] { - let candidate = root - .join("target") - .join(profile) - .join(exe_name("rivet-engine")); - if candidate.exists() { - return Some(candidate); - } - } - } - None -} - -fn cached_engine_path(version: &str) -> Result { - Ok(storage_root()? - .join("engine") - .join(version) - .join(engine_artifact_name())) -} - -async fn download_engine_binary(config: &EngineResolverConfig, destination: &Path) -> Result<()> { - let artifact = engine_artifact_name(); - let base = config.releases_endpoint.trim_end_matches('/'); - let artifact_url = format!("{base}/rivet/{}/engine/{artifact}", config.version); - let manifest_url = format!("{base}/rivet/{}/engine/SHA256SUMS", config.version); - let client = Client::builder() - .timeout(DOWNLOAD_TIMEOUT) - .build() - .context("build reqwest client for engine download")?; - - let manifest = fetch_text(&client, &manifest_url).await?; - let expected = checksum_for_artifact(&manifest, &artifact).ok_or_else(|| { - EngineProcessError::DownloadFailed { - url: manifest_url.clone(), - reason: format!("manifest does not contain `{artifact}`"), - } - .build() - })?; - - let bytes = fetch_bytes(&client, &artifact_url).await?; - let received = sha256_hex(&bytes); - if !received.eq_ignore_ascii_case(&expected) { - return Err(EngineProcessError::ChecksumMismatch { - artifact, - expected, - received, - } - .build()); - } - - let parent = destination - .parent() - .context("engine cache destination has no parent")?; - ensure_dir(parent)?; - std::fs::write(destination, bytes) - .with_context(|| format!("write engine binary `{}`", destination.display()))?; - make_executable(destination)?; - Ok(()) -} - -async fn fetch_text(client: &Client, url: &str) -> Result { - let response = client.get(url).send().await.map_err(|error| { - EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: error.to_string(), - } - .build() - })?; - if !response.status().is_success() { - let status = response.status(); - return Err(EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: format!("unexpected status {status}"), - } - .build()); - } - response.text().await.map_err(|error| { - EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: error.to_string(), - } - .build() - }) -} - -async fn fetch_bytes(client: &Client, url: &str) -> Result> { - let response = client.get(url).send().await.map_err(|error| { - EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: error.to_string(), - } - .build() - })?; - if !response.status().is_success() { - let status = response.status(); - return Err(EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: format!("unexpected status {status}"), - } - .build()); - } - response - .bytes() - .await - .map(|bytes| bytes.to_vec()) - .map_err(|error| { - EngineProcessError::DownloadFailed { - url: url.to_owned(), - reason: error.to_string(), - } - .build() - }) -} - -fn checksum_for_artifact(manifest: &str, artifact: &str) -> Option { - manifest.lines().find_map(|line| { - let mut parts = line.split_whitespace(); - let checksum = parts.next()?; - let name = parts.next()?.trim_start_matches('*'); - (checksum.len() == 64 && name == artifact).then(|| checksum.to_owned()) - }) -} - -fn sha256_hex(bytes: &[u8]) -> String { - let digest = Sha256::digest(bytes); - let mut out = String::with_capacity(digest.len() * 2); - for byte in digest { - use std::fmt::Write; - let _ = write!(&mut out, "{byte:02x}"); - } - out -} - -fn engine_artifact_name() -> String { - let arch = match std::env::consts::ARCH { - "x86_64" => "x86_64", - "aarch64" => "aarch64", - other => other, - }; - let target = match std::env::consts::OS { - "linux" => format!("{arch}-unknown-linux-musl"), - "macos" => format!("{arch}-apple-darwin"), - "windows" => format!("{arch}-pc-windows-gnu.exe"), - other => format!("{arch}-{other}"), - }; - if target.ends_with(".exe") { - format!("rivet-engine-{target}") - } else { - format!("rivet-engine-{target}") - } -} - -fn exe_name(base: &str) -> String { - if cfg!(windows) { - format!("{base}.exe") - } else { - base.to_owned() - } -} - -fn make_executable(path: &Path) -> Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut permissions = std::fs::metadata(path) - .with_context(|| format!("read metadata for `{}`", path.display()))? - .permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(path, permissions) - .with_context(|| format!("mark `{}` executable", path.display()))?; - } - #[cfg(not(unix))] - { - let _ = path; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; - - use super::*; - - fn test_config(releases_endpoint: String, auto_download: bool) -> EngineResolverConfig { - EngineResolverConfig { - endpoint: "http://127.0.0.1:1".to_owned(), - explicit_binary_path: None, - auto_download, - version: "test-version".to_owned(), - releases_endpoint, - } - } - - #[tokio::test] - async fn resolver_prefers_existing_engine_before_filesystem_paths() { - let temp = tempfile::tempdir().expect("create temp dir"); - let local = temp - .path() - .join("target") - .join("debug") - .join(exe_name("rivet-engine")); - std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir"); - std::fs::write(&local, b"local").expect("write local binary"); - let cached = temp.path().join("cache").join(exe_name("rivet-engine")); - - let resolved = resolve_engine_binary_after_probe( - &test_config("http://127.0.0.1:1".to_owned(), false), - true, - &[temp.path().to_path_buf()], - cached, - ) - .await - .expect("resolve engine"); - - assert_eq!(resolved, ResolvedEngine::Existing); - } - - #[tokio::test] - async fn resolver_prefers_local_binary_before_cached_binary() { - let temp = tempfile::tempdir().expect("create temp dir"); - let local = temp - .path() - .join("target") - .join("debug") - .join(exe_name("rivet-engine")); - std::fs::create_dir_all(local.parent().expect("local parent")).expect("create local dir"); - std::fs::write(&local, b"local").expect("write local binary"); - let cached = temp.path().join("cache").join(exe_name("rivet-engine")); - std::fs::create_dir_all(cached.parent().expect("cached parent")).expect("create cache dir"); - std::fs::write(&cached, b"cached").expect("write cached binary"); - - let resolved = resolve_engine_binary_after_probe( - &test_config("http://127.0.0.1:1".to_owned(), false), - false, - &[temp.path().to_path_buf()], - cached, - ) - .await - .expect("resolve engine"); - - assert_eq!(resolved, ResolvedEngine::Binary(local)); - } - - #[tokio::test] - async fn resolver_reuses_cached_binary_without_download() { - let temp = tempfile::tempdir().expect("create temp dir"); - let cached = temp.path().join("cache").join(exe_name("rivet-engine")); - std::fs::create_dir_all(cached.parent().expect("cached parent")).expect("create cache dir"); - std::fs::write(&cached, b"cached").expect("write cached binary"); - - let resolved = resolve_engine_binary_after_probe( - &test_config("http://127.0.0.1:1".to_owned(), false), - false, - &[], - cached.clone(), - ) - .await - .expect("resolve engine"); - - assert_eq!(resolved, ResolvedEngine::Binary(cached)); - } - - #[tokio::test] - async fn resolver_reports_actionable_error_without_binary_or_download() { - let temp = tempfile::tempdir().expect("create temp dir"); - let cached = temp.path().join("cache").join(exe_name("rivet-engine")); - - let error = resolve_engine_binary_after_probe( - &test_config("http://127.0.0.1:1".to_owned(), false), - false, - &[], - cached, - ) - .await - .expect_err("missing binary should fail"); - let message = error.to_string(); - - assert!(message.contains("No usable engine binary was found")); - assert!(message.contains("Build `rivet-engine`")); - assert!(message.contains("RIVET_ENGINE_BINARY_PATH")); - } - - #[tokio::test] - async fn resolver_download_checks_manifest_checksum() { - let temp = tempfile::tempdir().expect("create temp dir"); - let cached = temp.path().join("cache").join(exe_name("rivet-engine")); - let artifact = engine_artifact_name(); - let expected = sha256_hex(b"different bytes"); - let manifest = format!("{expected} {artifact}\n"); - let releases_endpoint = spawn_download_server(HashMap::from([ - ( - format!("/rivet/test-version/engine/SHA256SUMS"), - manifest.into_bytes(), - ), - ( - format!("/rivet/test-version/engine/{artifact}"), - b"actual bytes".to_vec(), - ), - ])) - .await; - - let error = resolve_engine_binary_after_probe( - &test_config(releases_endpoint, true), - false, - &[], - cached, - ) - .await - .expect_err("checksum mismatch should fail"); - - assert!( - error - .to_string() - .contains("Engine binary checksum mismatch") - ); - } - - async fn spawn_download_server(routes: HashMap>) -> String { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind download server"); - let addr = listener.local_addr().expect("download server address"); - tokio::spawn(async move { - for _ in 0..routes.len() { - let (mut socket, _) = listener.accept().await.expect("accept download request"); - let mut buffer = [0_u8; 2048]; - let n = socket - .read(&mut buffer) - .await - .expect("read download request"); - let request = String::from_utf8_lossy(&buffer[..n]); - let path = request - .split_whitespace() - .nth(1) - .expect("request path") - .to_owned(); - let body = routes.get(&path).expect("route body"); - let header = format!( - "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", - body.len() - ); - socket - .write_all(header.as_bytes()) - .await - .expect("write response header"); - socket.write_all(body).await.expect("write response body"); - } - }); - format!("http://{addr}") - } -} - -impl Drop for EngineProcessManager { - fn drop(&mut self) { - if let Some(handle) = self.watcher.take() { - // Aborting drops the `Child` owned by the task. With - // `kill_on_drop=false`, dropping the `Child` does NOT signal the - // engine, so the engine survives and gets reparented to init. - // We give up our crash-detection log line here, but if we are - // being dropped the rivetkit host is shutting down anyway. - handle.abort(); - tracing::debug!( - "aborted engine watcher; engine continues running (intentional orphan)" - ); - } - } -} - -/// Spawns a background task that owns the `Child` and awaits `wait()` so we -/// log a clear message if the engine dies while rivetkit is still up. Taking -/// the `Child` into the task also reaps it via `waitpid` on exit, so a -/// crashed engine never lingers as a zombie in our process table. -fn spawn_engine_watcher(mut child: Child, pid: u32) -> JoinHandle<()> { - tokio::spawn(async move { - match child.wait().await { - Ok(status) if status.success() => { - tracing::warn!( - pid, - ?status, - "engine process exited cleanly while rivetkit was still running; \ - rivetkit expected the engine to outlive it" - ); - } - Ok(status) => { - tracing::error!( - pid, - ?status, - "engine process crashed while rivetkit was still running" - ); - } - Err(error) => { - tracing::error!( - pid, - ?error, - "failed to wait on engine process; cannot detect crashes" - ); - } - } - }) -} - -/// Probes the configured endpoint for an already-running, healthy engine. -/// -/// Returns `Ok(Some(health))` if the endpoint is serving a `runtime: "engine"` -/// health response that we can reattach to. Returns `Ok(None)` if the port is -/// free. Returns `Err(...)` if the port is occupied by a non-engine process -/// (for example a stale rivetkit) which would conflict with a fresh spawn. -async fn probe_existing_engine(endpoint: &str) -> Result> { - let health_url = engine_health_url(endpoint); - let client = Client::builder() - .build() - .context("build reqwest client for engine probe")?; - - let response = match client - .get(&health_url) - .timeout(Duration::from_secs(1)) - .send() - .await - { - Ok(response) => response, - Err(_) => return Ok(None), - }; - - if !response.status().is_success() { - return Ok(None); - } - - let health = response - .json::() - .await - .context("decode existing engine health response")?; - - match health.runtime.as_deref() { - Some(ENGINE_RUNTIME) => Ok(Some(health)), - Some(RIVETKIT_RUNTIME) => Err(EngineProcessError::PortOccupied { - endpoint: endpoint.to_owned(), - runtime: RIVETKIT_RUNTIME.to_owned(), - } - .build()), - Some(other) => Err(EngineProcessError::PortOccupied { - endpoint: endpoint.to_owned(), - runtime: other.to_owned(), - } - .build()), - None => Err(EngineProcessError::PortOccupied { - endpoint: endpoint.to_owned(), - runtime: "unknown".to_owned(), - } - .build()), - } -} - -fn engine_health_url(endpoint: &str) -> String { - format!("{}/health", endpoint.trim_end_matches('/')) -} - -fn storage_root() -> Result { - if let Ok(path) = std::env::var("RIVETKIT_STORAGE_PATH") { - return Ok(PathBuf::from(path).join(".rivetkit")); - } - let home = std::env::var("HOME") - .map(PathBuf::from) - .or_else(|_| std::env::current_dir()) - .context("locate home directory for engine storage path")?; - Ok(home.join(".rivetkit")) -} - -fn ensure_dir(path: &Path) -> Result<()> { - std::fs::create_dir_all(path).with_context(|| format!("create directory `{}`", path.display())) -} - -fn open_log_file(path: &Path) -> Result { - std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - .with_context(|| format!("open log file `{}`", path.display())) -} - -fn log_timestamp() -> String { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); - format!("{}", now.as_secs()) -} - -async fn wait_for_engine_health(health_url: &str) -> Result { - const HEALTH_MAX_WAIT: Duration = Duration::from_secs(10); - const HEALTH_REQUEST_TIMEOUT: Duration = Duration::from_secs(1); - const HEALTH_INITIAL_BACKOFF: Duration = Duration::from_millis(100); - const HEALTH_MAX_BACKOFF: Duration = Duration::from_secs(1); - - let client = Client::builder() - .build() - .context("build reqwest client for engine health check")?; - let deadline = Instant::now() + HEALTH_MAX_WAIT; - let mut attempt = 0u32; - let mut backoff = HEALTH_INITIAL_BACKOFF; - - loop { - attempt += 1; - - let last_error = match client - .get(health_url) - .timeout(HEALTH_REQUEST_TIMEOUT) - .send() - .await - { - Ok(response) if response.status().is_success() => { - let health = response - .json::() - .await - .context("decode engine health response")?; - return Ok(health); - } - Ok(response) => format!("unexpected status {}", response.status()), - Err(error) => error.to_string(), - }; - - if Instant::now() >= deadline { - return Err(EngineProcessError::HealthCheckFailed { - attempts: attempt, - reason: last_error, - } - .build()); - } - - tokio::time::sleep(backoff).await; - backoff = std::cmp::min(backoff * 2, HEALTH_MAX_BACKOFF); - } -} - -/// Cleanup path for a spawn that never reached `healthy`. We *do* kill here -/// because the half-started engine has no useful state to preserve and -/// leaving it running would conflict with a retry. This is the only place -/// allowed to terminate the engine. -async fn terminate_failed_spawn(child: &mut Child) -> Result<()> { - const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); - - if child - .try_wait() - .context("check engine process status")? - .is_some() - { - return Ok(()); - } - - child - .start_kill() - .context("kill half-started engine process")?; - match tokio::time::timeout(SHUTDOWN_TIMEOUT, child.wait()).await { - Ok(result) => { - let status = result.context("wait for half-started engine to exit")?; - tracing::info!(?status, "half-started engine process exited"); - Ok(()) - } - Err(_) => { - tracing::warn!("half-started engine process did not exit within timeout"); - Ok(()) - } - } -} - -fn invalid_endpoint(endpoint: &str, reason: &str) -> anyhow::Error { - EngineProcessError::InvalidEndpoint { - endpoint: endpoint.to_owned(), - reason: reason.to_owned(), - } - .build() -} +//! The rivet-engine subprocess manager lives in the standalone +//! `rivetkit-engine-process` crate so the CLI and other hosts can reuse the +//! same resolution, spawn, reuse, and orphan-lifetime logic. This module +//! re-exports it for existing in-crate callers. + +pub use rivetkit_engine_process::{ + EngineProcessError, EngineProcessManager, EngineResolverConfig, ResolvedEngine, engine_db_path, + engine_env, resolve_engine_binary, resolve_engine_binary_path, +}; diff --git a/rivetkit-rust/packages/rivetkit-core/src/error.rs b/rivetkit-rust/packages/rivetkit-core/src/error.rs index 4241551135..83c9a9f3bb 100644 --- a/rivetkit-rust/packages/rivetkit-core/src/error.rs +++ b/rivetkit-rust/packages/rivetkit-core/src/error.rs @@ -227,63 +227,3 @@ pub(crate) enum SqliteRuntimeError { )] RemoteFenceMismatch { reason: String }, } - -#[derive(RivetError, Debug, Clone, Deserialize, Serialize)] -#[error("engine")] -pub(crate) enum EngineProcessError { - #[error( - "binary_not_found", - "Engine binary was not found.", - "Engine binary was not found at '{path}'." - )] - BinaryNotFound { path: String }, - - #[error( - "binary_unavailable", - "Engine binary is unavailable.", - "No usable engine binary was found for version '{version}'. Build `rivet-engine`, set `RIVET_ENGINE_BINARY_PATH`, or enable `RIVETKIT_ENGINE_AUTO_DOWNLOAD=1`." - )] - BinaryUnavailable { version: String }, - - #[error( - "download_failed", - "Engine binary download failed.", - "Engine binary download failed for '{url}': {reason}" - )] - DownloadFailed { url: String, reason: String }, - - #[error( - "checksum_mismatch", - "Engine binary checksum mismatch.", - "Engine binary checksum mismatch for '{artifact}': expected {expected}, received {received}." - )] - ChecksumMismatch { - artifact: String, - expected: String, - received: String, - }, - - #[error( - "invalid_endpoint", - "Engine endpoint is invalid.", - "Engine endpoint '{endpoint}' is invalid: {reason}" - )] - InvalidEndpoint { endpoint: String, reason: String }, - - #[error("missing_pid", "Engine process is missing a pid.")] - MissingPid, - - #[error( - "health_check_failed", - "Engine health check failed.", - "Engine health check failed after {attempts} attempts: {reason}" - )] - HealthCheckFailed { attempts: u32, reason: String }, - - #[error( - "port_occupied", - "Engine port is occupied by a different runtime.", - "Cannot start engine: endpoint '{endpoint}' is already serving runtime '{runtime}'. Stop that process and retry." - )] - PortOccupied { endpoint: String, runtime: String }, -} diff --git a/rivetkit-typescript/packages/cli/README.md b/rivetkit-typescript/packages/cli/README.md new file mode 100644 index 0000000000..899353f3d0 --- /dev/null +++ b/rivetkit-typescript/packages/cli/README.md @@ -0,0 +1,11 @@ +# @rivetkit/cli + +Rivet Cloud CLI distributed over npm. + +```sh +npx @rivetkit/cli dev +npx @rivetkit/cli dev --provider cloudflare +npx @rivetkit/cli dev --provider supabase -- --env-file .env.local +npx @rivetkit/cli deploy --token cloud_api_xxxxx +npx @rivetkit/cli setup-ci +``` diff --git a/rivetkit-typescript/packages/cli/index.d.ts b/rivetkit-typescript/packages/cli/index.d.ts new file mode 100644 index 0000000000..5d3a196c9b --- /dev/null +++ b/rivetkit-typescript/packages/cli/index.d.ts @@ -0,0 +1,5 @@ +/** Returns the absolute path to the rivet CLI binary for the current host. */ +export function getCliPath(): string; + +/** Returns the expected platform-specific npm package for the current host, or null if unsupported. */ +export function getPlatformPackageName(): string | null; diff --git a/rivetkit-typescript/packages/cli/index.js b/rivetkit-typescript/packages/cli/index.js new file mode 100644 index 0000000000..d15b09e257 --- /dev/null +++ b/rivetkit-typescript/packages/cli/index.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +const { existsSync } = require("node:fs"); +const { spawnSync } = require("node:child_process"); +const { dirname, join } = require("node:path"); + +function getPlatformPackageName() { + const { platform, arch } = process; + switch (platform) { + case "linux": + if (arch === "x64") return "@rivetkit/cli-linux-x64-musl"; + if (arch === "arm64") return "@rivetkit/cli-linux-arm64-musl"; + break; + case "darwin": + if (arch === "x64") return "@rivetkit/cli-darwin-x64"; + if (arch === "arm64") return "@rivetkit/cli-darwin-arm64"; + break; + case "win32": + if (arch === "x64") return "@rivetkit/cli-win32-x64"; + break; + } + return null; +} + +const BINARY_NAME = process.platform === "win32" ? "rivet.exe" : "rivet"; + +function getCliPath() { + if (process.env.RIVET_CLI_BINARY) { + if (!existsSync(process.env.RIVET_CLI_BINARY)) { + throw new Error( + `RIVET_CLI_BINARY is set to ${process.env.RIVET_CLI_BINARY} but the file does not exist`, + ); + } + return process.env.RIVET_CLI_BINARY; + } + + const localBinary = join(__dirname, BINARY_NAME); + if (existsSync(localBinary)) return localBinary; + + const platformPkg = getPlatformPackageName(); + if (!platformPkg) { + throw new Error( + `@rivetkit/cli: unsupported platform ${process.platform}/${process.arch}`, + ); + } + + let pkgJsonPath; + try { + pkgJsonPath = require.resolve(`${platformPkg}/package.json`); + } catch { + if (process.platform === "win32" && process.arch === "x64") { + const version = require("./package.json").version; + if ( + typeof version === "string" && + version.startsWith("0.0.0-") + ) { + throw new Error( + "@rivetkit/cli: Windows x64 binaries are only published for release versions.\n" + + `The current package version (${version}) is a preview build, so @rivetkit/cli-win32-x64 was intentionally not published.\n` + + "Use a release build or set RIVET_CLI_BINARY to a local rivet.exe binary.", + ); + } + } + throw new Error( + `@rivetkit/cli: platform package ${platformPkg} is not installed.\n` + + "Optional dependencies may have been skipped. Try npm install --include=optional @rivetkit/cli.", + ); + } + return join(dirname(pkgJsonPath), BINARY_NAME); +} + +if (require.main === module) { + const result = spawnSync(getCliPath(), process.argv.slice(2), { + stdio: "inherit", + env: process.env, + }); + if (result.error) throw result.error; + process.exit(result.status ?? 1); +} + +module.exports.getCliPath = getCliPath; +module.exports.getPlatformPackageName = getPlatformPackageName; diff --git a/rivetkit-typescript/packages/cli/npm/darwin-arm64/package.json b/rivetkit-typescript/packages/cli/npm/darwin-arm64/package.json new file mode 100644 index 0000000000..e0e852266d --- /dev/null +++ b/rivetkit-typescript/packages/cli/npm/darwin-arm64/package.json @@ -0,0 +1,19 @@ +{ + "name": "@rivetkit/cli-darwin-arm64", + "version": "2.3.0-rc.12", + "description": "Rivet CLI for macOS arm64 (Apple Silicon)", + "license": "LicenseRef-RivetEnterprise", + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "files": [ + "rivet", + "rivet-engine" + ], + "engines": { + "node": ">= 20.0.0" + } +} diff --git a/rivetkit-typescript/packages/cli/npm/darwin-x64/package.json b/rivetkit-typescript/packages/cli/npm/darwin-x64/package.json new file mode 100644 index 0000000000..cdef458ead --- /dev/null +++ b/rivetkit-typescript/packages/cli/npm/darwin-x64/package.json @@ -0,0 +1,19 @@ +{ + "name": "@rivetkit/cli-darwin-x64", + "version": "2.3.0-rc.12", + "description": "Rivet CLI for macOS x64", + "license": "LicenseRef-RivetEnterprise", + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "files": [ + "rivet", + "rivet-engine" + ], + "engines": { + "node": ">= 20.0.0" + } +} diff --git a/rivetkit-typescript/packages/cli/npm/linux-arm64-musl/package.json b/rivetkit-typescript/packages/cli/npm/linux-arm64-musl/package.json new file mode 100644 index 0000000000..320d926ac6 --- /dev/null +++ b/rivetkit-typescript/packages/cli/npm/linux-arm64-musl/package.json @@ -0,0 +1,19 @@ +{ + "name": "@rivetkit/cli-linux-arm64-musl", + "version": "2.3.0-rc.12", + "description": "Rivet CLI for Linux arm64 musl (static)", + "license": "LicenseRef-RivetEnterprise", + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "files": [ + "rivet", + "rivet-engine" + ], + "engines": { + "node": ">= 20.0.0" + } +} diff --git a/rivetkit-typescript/packages/cli/npm/linux-x64-musl/package.json b/rivetkit-typescript/packages/cli/npm/linux-x64-musl/package.json new file mode 100644 index 0000000000..e048b17ffd --- /dev/null +++ b/rivetkit-typescript/packages/cli/npm/linux-x64-musl/package.json @@ -0,0 +1,19 @@ +{ + "name": "@rivetkit/cli-linux-x64-musl", + "version": "2.3.0-rc.12", + "description": "Rivet CLI for Linux x64 musl (static)", + "license": "LicenseRef-RivetEnterprise", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "files": [ + "rivet", + "rivet-engine" + ], + "engines": { + "node": ">= 20.0.0" + } +} diff --git a/rivetkit-typescript/packages/cli/npm/win32-x64/package.json b/rivetkit-typescript/packages/cli/npm/win32-x64/package.json new file mode 100644 index 0000000000..20bc0208e8 --- /dev/null +++ b/rivetkit-typescript/packages/cli/npm/win32-x64/package.json @@ -0,0 +1,19 @@ +{ + "name": "@rivetkit/cli-win32-x64", + "version": "2.3.0-rc.12", + "description": "Rivet CLI for Windows x64", + "license": "LicenseRef-RivetEnterprise", + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "files": [ + "rivet.exe", + "rivet-engine.exe" + ], + "engines": { + "node": ">= 20.0.0" + } +} diff --git a/rivetkit-typescript/packages/cli/package.json b/rivetkit-typescript/packages/cli/package.json new file mode 100644 index 0000000000..981fabd86e --- /dev/null +++ b/rivetkit-typescript/packages/cli/package.json @@ -0,0 +1,20 @@ +{ + "name": "@rivetkit/cli", + "version": "2.3.0-rc.12", + "description": "Rivet Cloud CLI", + "license": "LicenseRef-RivetEnterprise", + "main": "index.js", + "types": "index.d.ts", + "bin": { + "rivet": "index.js" + }, + "engines": { + "node": ">= 20.0.0" + }, + "files": [ + "index.js", + "index.d.ts", + "package.json", + "README.md" + ] +} diff --git a/rivetkit-typescript/packages/engine-cli/index.js b/rivetkit-typescript/packages/engine-cli/index.js index 8c31656537..7c1b567b5d 100644 --- a/rivetkit-typescript/packages/engine-cli/index.js +++ b/rivetkit-typescript/packages/engine-cli/index.js @@ -83,7 +83,7 @@ function getEnginePath() { const version = require("./package.json").version; if ( typeof version === "string" && - (version.includes("-pr.") || version.includes("-main.")) + version.startsWith("0.0.0-") ) { throw new Error( "@rivetkit/engine-cli: Windows x64 binaries are only published for release versions.\n" + diff --git a/scripts/publish/src/ci/bin.ts b/scripts/publish/src/ci/bin.ts index cab832800d..9a46b8514d 100644 --- a/scripts/publish/src/ci/bin.ts +++ b/scripts/publish/src/ci/bin.ts @@ -31,7 +31,7 @@ import { tagAndPush, } from "../lib/git.js"; import { scoped } from "../lib/logger.js"; -import { publishAll } from "../lib/npm.js"; +import { publishAll, repairBranchPreviewLatestTags } from "../lib/npm.js"; import { copyPrefix, uploadDir, @@ -207,10 +207,10 @@ program .option("--release-mode", "Fail if every package is already published") .action(async (opts) => { const repoRoot = findRepoRoot(); + const ctx = await resolveContext(); let tag: string = opts.tag; let releaseMode: boolean | undefined = opts.releaseMode; if (!tag || releaseMode === undefined) { - const ctx = await resolveContext(); tag = tag ?? ctx.npmTag; if (opts.releaseMode === undefined) { releaseMode = ctx.trigger === "release"; @@ -218,11 +218,19 @@ program } await publishAll(repoRoot, { tag, + version: ctx.version, includeReleaseOnlyPackages: releaseMode, parallel: Number(opts.parallel), retries: Number(opts.retries), releaseMode, }); + if (!releaseMode) { + await repairBranchPreviewLatestTags(repoRoot, { + tag, + version: ctx.version, + includeReleaseOnlyPackages: releaseMode, + }); + } }); // --------------------------------------------------------------------------- @@ -458,7 +466,7 @@ program "", `All packages published as \`${version}\` with tag \`${tag}\`.`, "", - "Engine binary is shipped via `@rivetkit/engine-cli` on linux-x64-musl, linux-arm64-musl, darwin-x64, and darwin-arm64. Windows users should use the release installer or set `RIVET_ENGINE_BINARY`.", + "Engine binary is shipped via `@rivetkit/engine-cli` on linux-x64-musl, linux-arm64-musl, darwin-x64, and darwin-arm64. `@rivetkit/cli` ships the `rivet` binary plus bundled engine on the same platforms. Windows users should use release versions or set `RIVET_ENGINE_BINARY` / `RIVET_CLI_BINARY`.", "", "Docker images:", "```sh", diff --git a/scripts/publish/src/lib/npm.ts b/scripts/publish/src/lib/npm.ts index 91a6934866..3215ad65a9 100644 --- a/scripts/publish/src/lib/npm.ts +++ b/scripts/publish/src/lib/npm.ts @@ -10,6 +10,7 @@ import { scoped } from "./logger.js"; import { assertDiscoverySanity, discoverPackages, + META_PACKAGES, type Package, } from "./packages.js"; @@ -18,6 +19,8 @@ const log = scoped("npm"); export interface PublishAllOptions { /** npm dist-tag (e.g. pr-123, main, latest, rc, next). */ tag: string; + /** Version being published. Used to repair preview latest tags. */ + version?: string; /** Max simultaneous publishes. */ parallel?: number; /** Max retries per package. */ @@ -58,6 +61,11 @@ export interface PublishSummary { elapsedSeconds: number; } +interface PublishBatchResult { + results: PublishResult[]; + elapsedMs: number; +} + const ALREADY_PUBLISHED_PATTERNS = [ "cannot publish over the previously published versions", "cannot publish over previously published version", @@ -121,6 +129,59 @@ function runNpmPublish( }); } +function runNpmDistTagAdd( + pkg: Package, + version: string, + tag: string, +): Promise<{ code: number; output: string }> { + return new Promise((resolvePromise) => { + const child = spawn("npm", ["dist-tag", "add", `${pkg.name}@${version}`, tag], { + cwd: pkg.dir, + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }); + const chunks: Buffer[] = []; + child.stdout.on("data", (c) => chunks.push(c)); + child.stderr.on("data", (c) => chunks.push(c)); + child.on("close", (code) => { + resolvePromise({ + code: code ?? 1, + output: Buffer.concat(chunks).toString("utf8"), + }); + }); + }); +} + +function npmViewLatestTag(pkg: Package): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn("npm", ["view", pkg.name, "dist-tags.latest", "--json"], { + cwd: pkg.dir, + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }); + const chunks: Buffer[] = []; + child.stdout.on("data", (c) => chunks.push(c)); + child.stderr.on("data", (c) => chunks.push(c)); + child.on("close", (code) => { + const output = Buffer.concat(chunks).toString("utf8").trim(); + if (code !== 0) { + reject(new Error(`npm view ${pkg.name} latest failed: ${output}`)); + return; + } + if (!output || output === "null") { + resolvePromise(undefined); + return; + } + try { + const parsed = JSON.parse(output); + resolvePromise(typeof parsed === "string" ? parsed : undefined); + } catch { + resolvePromise(output); + } + }); + }); +} + async function publishOne( pkg: Package, opts: Required< @@ -154,6 +215,38 @@ async function publishOne( return { pkg, status: "failed", attempts: opts.retries + 1 }; } +export async function repairBranchPreviewLatestTags( + repoRoot: string, + opts: Required> & + Pick, +): Promise { + const previewPrefix = `0.0.0-${opts.tag}.`; + const packages = discoverPackages(repoRoot, { + includeReleaseOnly: opts.includeReleaseOnlyPackages, + }); + + for (const pkg of packages) { + const latest = await npmViewLatestTag(pkg); + if ( + latest === undefined || + latest === opts.version || + !latest.startsWith(previewPrefix) + ) { + continue; + } + + log.info( + `repairing ${pkg.name} latest tag: ${latest} -> ${opts.version}`, + ); + const result = await runNpmDistTagAdd(pkg, opts.version, "latest"); + if (result.code !== 0) { + throw new Error( + `npm dist-tag add ${pkg.name}@${opts.version} latest failed: ${extractError(result.output)}`, + ); + } + } +} + function printResult(r: PublishResult): void { const name = r.pkg.name.padEnd(48); const symbol = @@ -171,6 +264,42 @@ function printResult(r: PublishResult): void { log.info(` ${symbol} ${name}${suffix}`); } +async function publishBatch( + packages: Package[], + opts: Required< + Pick + >, +): Promise { + const queue = [...packages]; + const results: PublishResult[] = []; + const startedAt = Date.now(); + + async function worker(): Promise { + while (true) { + const pkg = queue.shift(); + if (!pkg) return; + const result = await publishOne(pkg, { + tag: opts.tag, + retries: opts.retries, + initialBackoffMs: opts.initialBackoffMs, + }); + printResult(result); + results.push(result); + } + } + + const workers: Promise[] = []; + for (let i = 0; i < Math.min(opts.parallel, packages.length); i++) { + workers.push(worker()); + } + await Promise.all(workers); + + return { + results, + elapsedMs: Date.now() - startedAt, + }; +} + export async function publishAll( repoRoot: string, opts: PublishAllOptions, @@ -189,27 +318,42 @@ export async function publishAll( `publishing ${packages.length} packages | tag=${tag} | parallel=${parallel} | retries=${retries}`, ); - const queue = [...packages]; - const results: PublishResult[] = []; - const startedAt = Date.now(); + const metaNames = new Set(META_PACKAGES.map((p) => p.meta)); + const platformPackages = packages.filter((p) => + META_PACKAGES.some(({ platformPrefix }) => + p.name.startsWith(platformPrefix), + ), + ); + const metaPackages = packages.filter((p) => metaNames.has(p.name)); + const otherPackages = packages.filter( + (p) => + !metaNames.has(p.name) && + !META_PACKAGES.some(({ platformPrefix }) => + p.name.startsWith(platformPrefix), + ), + ); - async function worker(): Promise { - while (true) { - const pkg = queue.shift(); - if (!pkg) return; - const result = await publishOne(pkg, { tag, retries, initialBackoffMs }); - printResult(result); - results.push(result); + const batchOpts = { tag, parallel, retries, initialBackoffMs }; + let elapsedMs = 0; + const results: PublishResult[] = []; + for (const [label, batch] of [ + ["platform packages", platformPackages], + ["meta packages", metaPackages], + ["regular packages", otherPackages], + ] as const) { + if (batch.length === 0) continue; + log.info(`publishing ${label} (${batch.length})`); + const batchResult = await publishBatch(batch, batchOpts); + elapsedMs += batchResult.elapsedMs; + results.push(...batchResult.results); + const failed = batchResult.results.filter((r) => r.status === "failed"); + if (failed.length > 0) { + log.error(`${label} failed; not publishing later batches`); + break; } } - const workers: Promise[] = []; - for (let i = 0; i < Math.min(parallel, packages.length); i++) { - workers.push(worker()); - } - await Promise.all(workers); - - const elapsed = (Date.now() - startedAt) / 1000; + const elapsed = elapsedMs / 1000; const counts = { success: results.filter((r) => r.status === "success").length, retried: results.filter((r) => r.status === "retried-success").length, diff --git a/scripts/publish/src/lib/packages.ts b/scripts/publish/src/lib/packages.ts index d9ca7675fe..58d007cfaf 100644 --- a/scripts/publish/src/lib/packages.ts +++ b/scripts/publish/src/lib/packages.ts @@ -72,10 +72,16 @@ export const META_PACKAGES: readonly MetaPackageSpec[] = [ meta: "@rivetkit/engine-cli", platformPrefix: "@rivetkit/engine-cli-", }, + { + meta: "@rivetkit/cli", + platformPrefix: "@rivetkit/cli-", + }, ]; export const RELEASE_ONLY_PACKAGES = new Set([ + "@rivetkit/rivetkit-napi-win32-x64-msvc", "@rivetkit/engine-cli-win32-x64", + "@rivetkit/cli-win32-x64", ]); function isPublishable(pkg: { name?: string; private?: boolean }): boolean { @@ -126,9 +132,11 @@ export function discoverPackages( // resolves at install time. // - rivetkit-napi: the N-API addon (.node files) // - engine-cli: the rivet-engine binary + // - cli: the rivet CLI binary plus bundled rivet-engine for (const metaRelDir of [ "rivetkit-typescript/packages/rivetkit-napi/npm", "rivetkit-typescript/packages/engine-cli/npm", + "rivetkit-typescript/packages/cli/npm", ]) { const npmDir = join(repoRoot, metaRelDir); if (!existsSync(npmDir)) continue; @@ -205,6 +213,7 @@ export function assertDiscoverySanity(packages: Package[]): void { "@rivetkit/react", "@rivetkit/rivetkit-napi", "@rivetkit/engine-cli", + "@rivetkit/cli", ]; const missing = required.filter((r) => !byName.has(r)); if (missing.length > 0) { diff --git a/website/src/components/docs/docsLandings.ts b/website/src/components/docs/docsLandings.ts index 3027bcc69a..85ad47a93a 100644 --- a/website/src/components/docs/docsLandings.ts +++ b/website/src/components/docs/docsLandings.ts @@ -8,8 +8,9 @@ import { faReact, faRust, faScaleBalanced, + faSupabase, } from "@rivet-gg/icons"; -import { deployOptions, faSupabase } from "@rivetkit/shared-data"; +import { deployOptions } from "@rivetkit/shared-data"; import type { DocsLandingData } from "./DocsLanding"; const actors: DocsLandingData = { @@ -24,7 +25,7 @@ const actors: DocsLandingData = { { title: "Node.js & Bun", href: "/docs/actors/quickstart/backend", icon: faNodeJs, description: "Set up actors with Node.js, Bun, and web frameworks." }, { title: "React", href: "/docs/actors/quickstart/react", icon: faReact, description: "Build realtime React applications backed by actors." }, { title: "Next.js", href: "/docs/actors/quickstart/next-js", icon: faNextjs, description: "Server-rendered Next.js experiences backed by actors." }, - { title: "Rust", href: "/docs/actors/quickstart/rust", icon: faRust, badge: "Beta", description: "Native Rust with the typed rivetkit crate." }, + { title: "Rust", href: "/docs/actors/quickstart/rust", icon: faRust, badge: "Beta", description: "Build a Rivet Actor in Rust." }, { title: "Effect.ts", href: "/docs/actors/quickstart/effect", icon: faLayerGroup, badge: "Beta", description: "The Effect SDK with typed Schema actions." }, { title: "Cloudflare Workers", href: "/docs/actors/quickstart/cloudflare", icon: faCloudflare, description: "Run RivetKit on Cloudflare Workers." }, { title: "Supabase Functions", href: "/docs/actors/quickstart/supabase", icon: faSupabase, description: "Run RivetKit on Supabase Edge Functions." }, diff --git a/website/src/content/docs/actors/quickstart/cloudflare.mdx b/website/src/content/docs/actors/quickstart/cloudflare.mdx new file mode 100644 index 0000000000..3ba9d0081d --- /dev/null +++ b/website/src/content/docs/actors/quickstart/cloudflare.mdx @@ -0,0 +1,107 @@ +--- +title: "Cloudflare Workers Quickstart" +description: "Set up a Rivet project locally targeting Cloudflare Workers." +skill: true +--- + +Set up a Rivet project locally that runs on Cloudflare Workers. Use the public `@rivetkit/rivetkit-wasm` package and pass the bindings through `setup({ wasm })`. + +## Steps + + + + +- [Node.js](https://nodejs.org/) + +The CLI runs the local Rivet engine as a bundled native binary, so no Docker is required. A Cloudflare account is only needed to deploy. + + + + +```sh +npm install rivetkit @rivetkit/rivetkit-wasm +npm install --save-dev wrangler +``` + + + + +Set local Rivet connection values as Worker variables. `rivet dev` registers the matching serverless runner for you. + +```toml wrangler.toml +name = "rivetkit-cloudflare" +main = "src/index.ts" +compatibility_date = "2025-04-01" +compatibility_flags = ["nodejs_compat"] + +[vars] +RIVET_ENDPOINT = "http://localhost:6420" +``` + + + + +```ts src/index.ts @nocheck +import { actor, setup } from "rivetkit"; +import * as wasmBindings from "@rivetkit/rivetkit-wasm"; +import wasmModule from "@rivetkit/rivetkit-wasm/rivetkit_wasm_bg.wasm"; + +interface Env { + RIVET_ENDPOINT: string; +} + +const counter = actor({ + state: { count: 0 }, + actions: { + increment: (c, amount = 1) => { + c.state.count += amount; + return c.state.count; + }, + }, +}); + +let registry: { handler(request: Request): Promise } | undefined; + +function getRegistry(env: Env) { + registry ??= setup({ + runtime: "wasm", + wasm: { + bindings: wasmBindings, + initInput: wasmModule, + }, + use: { counter }, + endpoint: env.RIVET_ENDPOINT, + }); + + return registry; +} + +export default { + async fetch(request: Request, env: Env): Promise { + return await getRegistry(env).handler(request); + }, +}; +``` + + + + +Start Rivet. The CLI runs the local engine and spawns `wrangler dev` for you: + +```sh +npx @rivetkit/cli dev --provider cloudflare +``` + + + + +Ready to ship? See [Deploying to Cloudflare Workers](/docs/deploy/cloudflare). + + + + +## Related + +- [Quickstart](/docs/actors/quickstart) +- [Deploying to Cloudflare Workers](/docs/deploy/cloudflare) +- [SQLite](/docs/actors/sqlite) diff --git a/website/src/content/docs/actors/quickstart/supabase.mdx b/website/src/content/docs/actors/quickstart/supabase.mdx new file mode 100644 index 0000000000..114b82530f --- /dev/null +++ b/website/src/content/docs/actors/quickstart/supabase.mdx @@ -0,0 +1,92 @@ +--- +title: "Supabase Functions Quickstart" +description: "Set up a Rivet project locally targeting Supabase Edge Functions." +skill: true +--- + +Set up a Rivet project locally that runs on Supabase Edge Functions. Use the public `@rivetkit/rivetkit-wasm` package and load the wasm file with Deno. + +## Steps + + + + +- [Node.js](https://nodejs.org/) +- [Supabase CLI](https://supabase.com/docs/guides/cli) +- Docker, for Supabase's local Edge Runtime + +The CLI runs the local Rivet engine as a bundled native binary, so Docker is only needed for Supabase itself. A Supabase project is only needed to deploy. + + + + +```sh +npx supabase functions new rivet +``` + +Add the packages used by the function: + +```sh +npm install rivetkit @rivetkit/rivetkit-wasm +``` + + + + +Supabase Functions run under Deno, so load the wasm bytes from the package export and pass them to `setup({ wasm })`. + +```ts supabase/functions/rivet/index.ts @nocheck +import { actor, setup } from "rivetkit"; +import * as wasmBindings from "@rivetkit/rivetkit-wasm"; + +const wasmModule = await Deno.readFile( + new URL(import.meta.resolve("@rivetkit/rivetkit-wasm/rivetkit_wasm_bg.wasm")), +); + +const counter = actor({ + state: { count: 0 }, + actions: { + increment: (c, amount = 1) => { + c.state.count += amount; + return c.state.count; + }, + }, +}); + +const registry = setup({ + runtime: "wasm", + wasm: { + bindings: wasmBindings, + initInput: wasmModule, + }, + use: { counter }, + endpoint: Deno.env.get("RIVET_ENDPOINT"), +}); + +Deno.serve(async (request) => { + return await registry.handler(request); +}); +``` + + + + +Start Rivet. The CLI runs the local engine, spawns `supabase functions serve` for you, and populates the connection values: + +```sh +npx @rivetkit/cli dev --provider supabase +``` + + + + +Ready to ship? See [Deploying to Supabase Functions](/docs/deploy/supabase). + + + + +## Related + +- [Quickstart](/docs/actors/quickstart) +- [Deploying to Supabase Functions](/docs/deploy/supabase) +- [SQLite](/docs/actors/sqlite) diff --git a/website/src/content/docs/deploy/cli.mdx b/website/src/content/docs/deploy/cli.mdx new file mode 100644 index 0000000000..d3216da940 --- /dev/null +++ b/website/src/content/docs/deploy/cli.mdx @@ -0,0 +1,154 @@ +--- +title: "CLI" +description: "The rivet CLI runs a local engine and dev server, proxies the engine, deploys to Rivet Cloud, and scaffolds CI." +skill: true +--- + +The `rivet` CLI is distributed as `@rivetkit/cli`. Run it with your package runner: + +```bash +npx @rivetkit/cli +``` + +| Command | Description | +| --- | --- | +| `rivet dev` | Run a local Rivet engine and the dev server for your handler. | +| `rivet engine` | Run the bundled `rivet-engine` binary directly (proxies all arguments). | +| `rivet deploy` | Build and deploy the current project to Rivet Cloud. | +| `rivet setup-ci` | Install the GitHub Actions workflow that deploys to Rivet Cloud. | + +## `rivet dev` + +Starts a local Rivet engine, optionally spawns your dev server, registers the serverless runner that points at it, and supervises everything under one Ctrl-C. + +```bash +rivet dev [--provider ] [--port N] [--fn-name NAME] [--url URL] [-- ...] +``` + +The local engine is started once and kept running across `rivet dev` restarts (it is intentionally orphaned). Pressing Ctrl-C stops the dev server but leaves the engine running. Use [`rivet engine`](#rivet-engine) to manage it. + +### Providers + +`--provider` selects how the dev server is launched and how its port is determined. Omit it to run a custom dev server you point at yourself. + +| `--provider` | Spawns | Port | Handler URL | +| --- | --- | --- | --- | +| _(omitted)_ | your `-- ` | from `--port` (required unless `--url`) | `http://127.0.0.1:{port}/api/rivet` | +| `serverless` | your `-- ` | auto-assigned free port, passed as the `PORT` environment variable | `http://127.0.0.1:{port}/api/rivet` | +| `cloudflare` | `npx wrangler dev --port {port}` | `8787` | `http://127.0.0.1:{port}/api/rivet` | +| `supabase` | `npx supabase functions serve {fn-name} --no-verify-jwt` | `54321` | `http://127.0.0.1:{port}/functions/v1/{fn-name}/api/rivet` | +| `none` | nothing | — | — (engine only) | + +For `cloudflare` and `supabase`, anything after `--` is appended to the preset command. For the default and `serverless` modes, everything after `--` is the command to run. + +### Options + +| Flag | Default | Description | +| --- | --- | --- | +| `--provider` | _unset_ | Serverless platform preset. See above. | +| `--port` | per-provider | Handler port. Required in the default mode unless `--url` is set. | +| `--fn-name` | `rivet` | Supabase function name (used in the spawned command and URL path). | +| `--url` | _derived_ | Explicit full handler URL. Overrides port and path construction. | +| `--engine-binary` | _resolved_ | Path to a `rivet-engine` binary. See [Engine binary resolution](#engine-binary-resolution). | + +### Examples + +```bash +# Run your own dev server and tell the CLI where it listens +rivet dev --port 3000 -- npm run dev + +# Generic serverless handler; the CLI picks a free port and sets PORT +rivet dev --provider serverless -- node handler.js + +# Cloudflare Workers, zero config +rivet dev --provider cloudflare + +# Supabase Functions with extra arguments appended +rivet dev --provider supabase -- --inspect + +# Run only the engine +rivet dev --provider none +``` + +## `rivet engine` + +Runs the bundled `rivet-engine` binary directly. Every argument after `engine` is forwarded verbatim, configured to use the same local database and ports as `rivet dev`. + +```bash +rivet engine ... +``` + +```bash +# Wipe local engine state +rivet engine nuke + +# Inspect workflows +rivet engine wf list + +# Query UniversalDB directly +rivet engine udb -q 'ls 0/1/2' +``` + +## `rivet deploy` + +Builds your project's Docker image, pushes it to Rivet's built-in registry, and upserts the managed pool. Prints the deployment dashboard URL to stdout when the pool is ready. See [Deploying to Rivet Compute](/docs/deploy/rivet-compute) for the full guide. + +```bash +rivet deploy --token cloud_api_xxxxx +``` + +The `--token` flag saves the token to `~/.rivet/credentials`, so later deploys can omit it: + +```bash +rivet deploy +``` + +| Flag | Default | Description | +| --- | --- | --- | +| `--token` | _from env / credentials_ | Rivet Cloud API token. Also written to `~/.rivet/credentials`. | +| `--namespace` | `production` | Cloud namespace to deploy to. | +| `--project` | _from token_ | Override the project resolved from the token. | +| `--org` | _from token_ | Override the organization resolved from the token. | +| `--dockerfile` | `Dockerfile` | Dockerfile to build. | +| `--build-context` | `.` | Docker build context. | +| `--env KEY=VAL` | _none_ | Environment override, repeatable. | +| `--image` | _project slug_ | Image repository name in Rivet's registry. | +| `--tag` | _git short SHA_ | Image tag. Falls back to a timestamp outside a git repo. | + +The token resolves from, in order: `--token`, the `RIVET_CLOUD_TOKEN` environment variable, then `~/.rivet/credentials`. + +## `rivet setup-ci` + +Installs `.github/workflows/rivet-deploy.yml`, the GitHub Actions workflow that deploys to Rivet Cloud on every push and pull request. + +```bash +rivet setup-ci +``` + +Then add your token as a repository secret: + +```bash +gh secret set RIVET_CLOUD_TOKEN +``` + +| Flag | Default | Description | +| --- | --- | --- | +| `--force` | `false` | Overwrite the workflow file if it already exists. | + +## Engine binary resolution + +`rivet dev` and `rivet engine` resolve the `rivet-engine` binary in this order: + +1. The `--engine-binary` flag. +2. The `RIVET_ENGINE_BINARY_PATH` environment variable. +3. A binary bundled next to the CLI. +4. A local build under `target/{debug,release}`. +5. An auto-downloaded release. + +Auto-download is enabled by default. Set `RIVETKIT_ENGINE_AUTO_DOWNLOAD=0` to require a local binary instead. + +## Related + +- [Deploying to Rivet Compute](/docs/deploy/rivet-compute) +- [Cloudflare Workers Quickstart](/docs/actors/quickstart/cloudflare) +- [Supabase Functions Quickstart](/docs/actors/quickstart/supabase) diff --git a/website/src/content/docs/deploy/cloudflare.mdx b/website/src/content/docs/deploy/cloudflare.mdx index 39d0f3d2b8..9bee9cbdb9 100644 --- a/website/src/content/docs/deploy/cloudflare.mdx +++ b/website/src/content/docs/deploy/cloudflare.mdx @@ -22,7 +22,7 @@ Follow the [Cloudflare Workers Quickstart](/docs/actors/quickstart/cloudflare) t -Set your Rivet connection values as Worker variables. The pool name must match the serverless runner configured in Rivet. +Set your Rivet endpoint as a Worker variable. Include your namespace and token in the URL. ```toml wrangler.toml name = "rivetkit-cloudflare" @@ -31,11 +31,7 @@ compatibility_date = "2025-04-01" compatibility_flags = ["nodejs_compat"] [vars] -RIVET_ENDPOINT = "https://api.rivet.dev" -RIVET_NAMESPACE = "your-namespace" -RIVET_POOL = "cloudflare-workers" -RIVET_TOKEN = "sk_..." -RIVET_PUBLIC_ENDPOINT = "https://your-namespace:pk_...@api.rivet.dev" +RIVET_ENDPOINT = "https://your-namespace:sk_...@api.rivet.dev" ``` @@ -53,14 +49,6 @@ After deploy, set the Worker URL with the `/api/rivet` path as the serverless ru -## Runtime Notes - -- Use `runtime: "wasm"` in `setup(...)` for Workers. You can also set `RIVETKIT_RUNTIME=wasm` in environments where the registry config does not set `runtime`. -- Pass `wasm: { bindings, initInput }` explicitly from `@rivetkit/rivetkit-wasm`. -- Use remote SQLite on Workers. Leaving SQLite unset with `runtime: "wasm"` selects remote SQLite automatically. -- Keep `RIVET_PUBLIC_ENDPOINT` pointed at the client-facing Rivet endpoint. Register the Worker URL separately as the serverless runner URL. -- Local Workers runtimes must support outbound WebSockets for the Rivet envoy connection. - ## Related - [Cloudflare Workers Quickstart](/docs/actors/quickstart/cloudflare) diff --git a/website/src/content/docs/deploy/rivet-compute.mdx b/website/src/content/docs/deploy/rivet-compute.mdx new file mode 100644 index 0000000000..ade043f0c5 --- /dev/null +++ b/website/src/content/docs/deploy/rivet-compute.mdx @@ -0,0 +1,130 @@ +--- +title: "Deploying to Rivet Compute" +description: "Run your backend on Rivet Compute." +skill: true +--- + + +Rivet Compute is currently in beta. + + + +Using an AI coding agent? Open **Connect** on the [Rivet dashboard](https://dashboard.rivet.dev), select **Rivet Cloud**, and paste the one-shot prompt into your agent and have it connect with Rivet Compute for you. + + +## Steps + + + + +- Your RivetKit app + - If you don't have one, see the [Quickstart](/docs/actors/quickstart) page or our [Examples](https://github.com/rivet-dev/rivet/tree/main/examples) +- A [Rivet Cloud](https://dashboard.rivet.dev) account and project +- Docker running locally + + + + +Add a `Dockerfile` to your project root that builds and runs your RivetKit server: + +```dockerfile @nocheck +FROM node:24-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY . . +CMD ["node", "src/server.js"] +``` + + + + +1. Open the [Rivet dashboard](https://dashboard.rivet.dev) and navigate to your project +2. Click **Connect** and select **Rivet Cloud** +3. Copy the **`RIVET_CLOUD_TOKEN`** value shown + + + + +Run the deploy command from your project root. The token is saved to `~/.rivet/credentials`, so later deploys can omit it. + +```bash +npx @rivetkit/cli deploy --token cloud_api_xxxxx +``` + +The CLI resolves your project from the token, builds and pushes your Docker image to Rivet's built-in registry, upserts the managed pool, and prints the deployment URL on stdout when the pool is ready. + + + + +After local deploys work, install the GitHub Actions workflow that deploys on every push and pull request: + +```bash +npx @rivetkit/cli setup-ci +``` + +This writes `.github/workflows/rivet-deploy.yml`. Add your token as a repository secret to enable it: + +```bash +gh secret set RIVET_CLOUD_TOKEN +``` + +The workflow creates production and pull-request namespaces, posts preview links, and cleans up PR namespaces when pull requests close. See the [CLI reference](/docs/deploy/cli) for all commands. + + + + +The dashboard shows live status as Rivet Compute provisions your backend: + +| Status | Description | +| --- | --- | +| Provisioning | Allocating compute resources | +| Initializing | Starting the runtime environment | +| Allocating | Assigning the runner to your pool | +| Deploying | Pulling and launching your container | +| Binding | Connecting the runner to the network | +| Ready | Deployment complete | + +Once the status reaches **Ready**, your backend is live and actors are available for connections. + + + +If you are an agent monitoring the deployment via API rather than the dashboard, poll the managed-pool endpoint on the Cloud API. + +The `RIVET_CLOUD_TOKEN` secret is a `cloud_api_*` management token scoped to the Cloud API at `cloud-api.rivet.dev`. Use it for `Authorization: Bearer ...` against the Cloud API. Do not confuse it with a `pk_*` publishable key, which is scoped to the Rivet Engine API at `api.rivet.dev` and will 401 against this endpoint. + +Substitute `$CLOUD_API_URL` (typically `https://cloud-api.rivet.dev`), `$PROJECT`, `$ORG`, `$CLOUD_NAMESPACE`, and `$CLOUD_TOKEN`. + +Poll every 5 seconds until `status` is `ready`. Stop and investigate if `status` is `error`. + +```bash +curl -s "$CLOUD_API_URL/projects/$PROJECT/namespaces/$CLOUD_NAMESPACE/managed-pools/default?org=$ORG" -H "Authorization: Bearer $CLOUD_TOKEN" +``` + + + + + + +## Troubleshooting + + + + +If the status stays in **Provisioning** for more than a few minutes, verify that: + +- The `RIVET_CLOUD_TOKEN` secret is correctly set in your GitHub repository +- The GitHub Actions workflow completed without errors — check the run logs + + + + +If the status shows **Error**, check that your container starts successfully and does not exit immediately. Common causes: + +- The server file is not calling `registry.startRunner()` +- A runtime crash on startup — test the image locally with `docker run` +- The Dockerfile is not listening on the `PORT` environmental variable + + + + diff --git a/website/src/content/docs/deploy/supabase.mdx b/website/src/content/docs/deploy/supabase.mdx index 797f12dd2c..b158622e8f 100644 --- a/website/src/content/docs/deploy/supabase.mdx +++ b/website/src/content/docs/deploy/supabase.mdx @@ -22,15 +22,10 @@ Follow the [Supabase Functions Quickstart](/docs/actors/quickstart/supabase) to -Set the Rivet connection values as Supabase secrets. The pool name must match the serverless runner configured in Rivet. +Set your Rivet endpoint as a Supabase secret. Include your namespace and token in the URL. ```sh -npx supabase secrets set \ - RIVET_ENDPOINT=https://api.rivet.dev \ - RIVET_PUBLIC_ENDPOINT=https://your-namespace:pk_...@api.rivet.dev \ - RIVET_NAMESPACE=your-namespace \ - RIVET_POOL=supabase-functions \ - RIVET_TOKEN=sk_... +npx supabase secrets set RIVET_ENDPOINT=https://your-namespace:sk_...@api.rivet.dev ``` @@ -48,14 +43,6 @@ After deploy, set the function URL with the `/api/rivet` path as the serverless -## Runtime Notes - -- Use `runtime: "wasm"` in `setup(...)` for Supabase Functions. You can also set `RIVETKIT_RUNTIME=wasm` in environments where the registry config does not set `runtime`. -- Pass `wasm: { bindings, initInput }` explicitly from `@rivetkit/rivetkit-wasm`. -- Use remote SQLite on Supabase Functions. Leaving SQLite unset with `runtime: "wasm"` selects remote SQLite automatically. -- Keep `RIVET_PUBLIC_ENDPOINT` pointed at the client-facing Rivet endpoint. Register the function URL separately as the serverless runner URL. -- Supabase Functions run in Deno, so load the wasm module with Deno-friendly bytes, URL, response, or module input. - ## Related - [Supabase Functions Quickstart](/docs/actors/quickstart/supabase) diff --git a/website/src/content/docs/quickstart/index.mdx b/website/src/content/docs/quickstart/index.mdx index 289cd6daf5..d3cc03abf7 100644 --- a/website/src/content/docs/quickstart/index.mdx +++ b/website/src/content/docs/quickstart/index.mdx @@ -10,8 +10,8 @@ import { faReact, faNextjs, faRust, + faSupabase, } from "@rivet-gg/icons"; -import { faSupabase } from "@rivetkit/shared-data"; - Build a Rivet Actor in Rust with the typed `rivetkit` crate + Build a Rivet Actor in Rust